mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-09-21 17:04:00 +00:00
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:
co-authored by
Claude Fable 5
parent
59a0e0be13
commit
24d36db601
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user