Address review on the test framework

- The registry is a Suite, created around the environment factory it runs its
  tests with, so the plugin's env.lua reads as: build environments, hand the
  test files a suite of them. The suite returned there becomes `...` in each
  test file, which is now said where it happens.
- pass and fail are the primitives every other check goes through. eq and
  deep_eq assert rather than compare, failing with both values in the detail.
- Stubs are extended through extend_requires, extend_script, extend_game and
  extend_defines, a recursive merge which raises when a value already exists,
  rather than by mutating the tables. The strict labels carry the factorio
  class names.
- The stubs record registered metatables and can save and load the script
  data the way factorio does: functions are refused and only registered
  metatables survive. env.save_load() uses it, and a new on_load test shows
  role methods and held roles surviving the round trip, which only passes
  because the module registers its metatable.
- The names helper moved out of the shared framework, it is role specific.
- Tests are named after the function or method they cover, on both sides:
  the lua tests read as "role:assign applies locally and is sent", and the
  javascript files wrap their tests in the class they exercise with subtests
  per method. module.test.js is control.test.js, after the file it covers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bbassie
2026-08-22 22:21:39 +00:00
co-authored by Claude Fable 5
parent 24d36db601
commit 7adcdde9dc
13 changed files with 592 additions and 448 deletions
@@ -6,6 +6,9 @@ const { reportLuaTests } = require("../../test/lua/runner");
const envFile = path.join(__dirname, "lua", "env.lua");
// Each file runs in its own lua state, and each test in a fresh environment
t.test("module/control.lua", t2 => {
for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) {
t.test(file, subtest => reportLuaTests(subtest, envFile, path.join(__dirname, "lua", file)));
t2.test(`lua/${file}`, subtest => reportLuaTests(subtest, envFile, path.join(__dirname, "lua", file)));
}
t2.end();
});
+11 -8
View File
@@ -65,7 +65,8 @@ async function startPlugin(t2, { roles = [], users = [], config = {} } = {}) {
const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions));
t.test("init creates properties for existing roles and sweeps orphans", async t2 => {
t.test("class ControllerPlugin", t2 => {
t2.test("init and sweepRoleMeta manage the role properties", async t2 => {
const { plugin } = await startPlugin(t2, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] });
t2.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first");
@@ -77,7 +78,7 @@ t.test("init creates properties for existing roles and sweeps orphans", async t2
t2.ok(plugin.roleMeta.get(5), "properties with a role are kept");
});
t.test("role records combine the role with its properties", async t2 => {
t2.test("buildRoleRecord combines the role with its properties", async t2 => {
const { plugin } = await startPlugin(t2, {
roles: [role(1, "Player"), role(5, "Moderator", ["exp_scenario.command.kill"])],
});
@@ -91,7 +92,7 @@ t.test("role records combine the role with its properties", async t2 => {
t2.ok(plugin.buildRoleRecord(plugin.controller.roles.get(1)).isDefault, "the default role is marked");
});
t.test("role changes broadcast to subscribers", async t2 => {
t2.test("rolesUpdated and roleMetaUpdated broadcast to subscribers", async t2 => {
const { plugin, controller, state } = await startPlugin(t2, { roles: [role(1, "Player")] });
state.broadcasts.length = 0;
@@ -110,7 +111,7 @@ t.test("role changes broadcast to subscribers", async t2 => {
t2.ok(state.broadcasts.some(event => event instanceof messages.RoleUpdatedEvent), "a default role change rebroadcasts");
});
t.test("subscriptions replay only newer records", async t2 => {
t2.test("handleRoleSubscription replays only newer records", async t2 => {
const { plugin, controller } = await startPlugin(t2, { roles: [role(1, "Player")] });
controller.roles.set(role(5, "Moderator"));
const updatedAtMs = plugin.buildRoleRecord(controller.roles.get(5)).updatedAtMs;
@@ -121,7 +122,7 @@ t.test("subscriptions replay only newer records", async t2 => {
t2.strictSame(none, null, "nothing is replayed when up to date");
});
t.test("assignments mirror users without the default role", async t2 => {
t2.test("listAssignmentRecords mirrors users without the default role", async t2 => {
const alice = new FakeUser("alice", [lib.Role.DefaultPlayerRoleId, 5]);
alice.updatedAtMs = 10;
const bob = new FakeUser("bob", [lib.Role.DefaultPlayerRoleId]);
@@ -133,7 +134,7 @@ t.test("assignments mirror users without the default role", async t2 => {
t2.strictSame([...records[0].roleIds], [5], "the default role is not listed");
});
t.test("assignment updates validate the user and roles", async t2 => {
t2.test("handleAssignmentUpdateRequest validates the user and roles", async t2 => {
const alice = new FakeUser("alice", [5]);
const { plugin, state } = await startPlugin(t2, {
roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")], users: [alice],
@@ -153,7 +154,7 @@ t.test("assignment updates validate the user and roles", async t2 => {
t2.ok(state.permissionUpdates.includes("alice"), "permission changes are pushed to the user");
});
t.test("roles are granted from online time", async t2 => {
t2.test("applyAutoAssign and onPlayerEvent grant roles from online time", async t2 => {
const newcomer = new FakeUser("newcomer");
newcomer.playerStats.onlineTimeMs = 3600000;
const veteran = new FakeUser("veteran");
@@ -177,7 +178,7 @@ t.test("roles are granted from online time", async t2 => {
t2.ok(veteran.roleIds.has(6), "granted again when the player leaves");
});
t.test("seeding creates the roles and reuses them by name", async t2 => {
t2.test("handleSeedRolesRequest creates the roles and reuses them by name", async t2 => {
const { plugin, controller } = await startPlugin(t2, {
roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")],
});
@@ -193,3 +194,5 @@ t.test("seeding creates the roles and reuses them by name", async t2 => {
await plugin.handleSeedRolesRequest();
t2.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles");
});
t2.end();
});
+12 -9
View File
@@ -54,7 +54,8 @@ function decodeRcon(command) {
return { receiver: match[1], payload: JSON.parse(match[2]) };
}
t.test("start subscribes and initialises the lua module", async t2 => {
t.test("class InstancePlugin", t2 => {
t2.test("onStart subscribes and initialises the lua module", async t2 => {
const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" });
await plugin.onStart();
@@ -75,28 +76,28 @@ t.test("start subscribes and initialises the lua module", async t2 => {
t2.strictSame(state.warnings.length, 0, "no warnings with a default role");
});
t.test("bidirectional emits changes back", async t2 => {
t2.test("onStart with bidirectional emits changes back", async t2 => {
const { plugin, state } = await startPlugin(t2);
await plugin.onStart();
const emit = decodeRcon(state.rcons[state.rcons.length - 1]);
t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]);
});
t.test("disabled does nothing on start", async t2 => {
t2.test("onStart with disabled does nothing", async t2 => {
const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" });
await plugin.onStart();
t2.strictSame(state.sent.length, 0, "nothing is sent");
t2.strictSame(state.rcons.length, 0, "no commands are run");
});
t.test("a missing default role is warned about", async t2 => {
t2.test("onStart warns when no default role is set", async t2 => {
const roles = sampleRoles().map(record => { record.isDefault = false; return record; });
const { plugin, state } = await startPlugin(t2, { roles });
await plugin.onStart();
t2.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role");
});
t.test("updates from the controller are forwarded to lua", async t2 => {
t2.test("handleRoleUpdatedEvent and handleAssignmentUpdatedEvent forward to lua", async t2 => {
const { plugin, state } = await startPlugin(t2);
await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles()));
const roles = decodeRcon(state.rcons[0]);
@@ -108,14 +109,14 @@ t.test("updates from the controller are forwarded to lua", async t2 => {
t2.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded");
});
t.test("updates are not forwarded while disabled", async t2 => {
t2.test("handleRoleUpdatedEvent and handleAssignmentUpdatedEvent are gated while disabled", async t2 => {
const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" });
await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles()));
await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments()));
t2.strictSame(state.rcons.length, 0, "no commands are run");
});
t.test("in game changes are sent up and rolled back when refused", async t2 => {
t2.test("handleAssignmentUpdateIPC sends the change and rolls back a refusal", async t2 => {
const { plugin, state } = await startPlugin(t2);
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined });
const request = state.sent[0];
@@ -129,15 +130,17 @@ t.test("in game changes are sent up and rolled back when refused", async t2 => {
t2.strictSame(rollback.payload, { name: "alice", role_ids: [5] }, "the rollback names the refused roles");
});
t.test("in game changes are dropped unless bidirectional", async t2 => {
t2.test("handleAssignmentUpdateIPC is gated unless bidirectional", async t2 => {
const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" });
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined });
t2.strictSame(state.sent.length, 0, "nothing is sent");
});
t.test("changing the sync mode toggles emit", async t2 => {
t2.test("onInstanceConfigFieldChanged toggles emit with the sync mode", async t2 => {
const { plugin, state } = await startPlugin(t2);
await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional");
const emit = decodeRcon(state.rcons[0]);
t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting");
});
t2.end();
});
+40 -34
View File
@@ -1,7 +1,6 @@
--- Assignment through role methods, confirmation, and rejection
local Env = ...
local Test = Env.Test
local test, check, eq = Test.test, Test.check, Test.eq
--- Tests for the assignment methods of module/control.lua
local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq = Suite.test, Suite.check, Suite.eq
--- alice is a moderator and bob a regular, with changes sent to the controller
local function setup(env)
@@ -10,38 +9,41 @@ local function setup(env)
bob = env.add_player("bob", 2),
}
env.initialise{
Env.assignment("alice", { 5 }),
Env.assignment("bob", { 6 }),
env.assignment("alice", { 5 }),
env.assignment("bob", { 6 }),
}
env.Roles.set_emit_events(true)
env.reset_log()
return players
end
test("assign applies locally and is sent to the controller", function(env)
test("role:assign applies locally and is sent to the controller", function(env)
local players = setup(env)
env.R("Moderator"):assign(players.bob, { by_player_name = "alice" })
check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "the assignment is sent")
check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "the payload names the role and player")
check(env.sent[1].data.name == "bob", "the payload names the player")
eq(env.sent[1].data.assign, { 5 }, "the payload names the role")
check(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation")
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event carries the assigned role id")
check(#env.events == 1, "one event is raised")
eq(env.events[1].data.assigned, { 5 }, "the event carries the assigned role id")
check(env.events[1].data.by_player_index == 1, "the event carries who made the change")
check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced")
check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "the assign sound plays")
eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays")
local sd = env.Roles._script_data()
check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "the role is held locally and pending")
eq(sd.local_players.bob, { 5 }, "the role is held locally")
eq(sd.pending.bob, { 5 }, "the role is pending")
env.reset_log()
env.R("Moderator"):assign(players.bob)
check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op")
end)
test("a confirmation releases the local hold", function(env)
test("receive_assignment_updates releases the local hold once confirmed", function(env)
local players = setup(env)
env.R("Moderator"):assign(players.bob)
env.reset_log()
env.Roles.receive_assignment_updates{ Env.assignment("bob", { 6, 5 }) }
env.Roles.receive_assignment_updates{ env.assignment("bob", { 6, 5 }) }
check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing")
local sd = env.Roles._script_data()
@@ -49,17 +51,19 @@ test("a confirmation releases the local hold", function(env)
check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation")
end)
test("unassign a synced role", function(env)
test("role:unassign removes a synced role", function(env)
local players = setup(env)
env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true })
check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 6 }), "the unassignment is sent")
check(#env.sent == 1, "the unassignment is sent")
eq(env.sent[1].data.unassign, { 6 }, "the payload names the role")
check(not env.R("Regular"):has_player(players.bob), "the role is removed locally")
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 6 }), "the event carries the unassigned role id")
check(#env.events == 1, "one event is raised")
eq(env.events[1].data.unassigned, { 6 }, "the event carries the unassigned role id")
check(#env.printed == 0, "silent suppresses the announcement")
check(#env.sounds == 1 and env.sounds[1] == "bob:utility/game_lost", "the unassign sound plays")
eq(env.sounds, { "bob:utility/game_lost" }, "the unassign sound plays")
end)
test("a rejection rolls the assignment back", function(env)
test("reject_assignment rolls the assignment back", function(env)
local players = setup(env)
env.R("Jail"):assign(players.alice)
check(env.R("Jail"):has_player(players.alice), "the role applies before rejection")
@@ -68,52 +72,54 @@ test("a rejection rolls the assignment back", function(env)
env.Roles.reject_assignment{ name = "alice", role_ids = { 7 } }
check(not env.R("Jail"):has_player(players.alice), "the rejected role is rolled back")
check(#env.sent == 0, "the rollback is not sent to the controller")
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 7 }), "the rollback raises the event")
check(#env.events == 1, "the rollback raises the event")
eq(env.events[1].data.unassigned, { 7 }, "the event carries the refused role id")
local sd = env.Roles._script_data()
check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state")
end)
test("local only roles never reach the controller", function(env)
test("role:assign with local_only never reaches the controller", function(env)
local players = setup(env)
env.R("Regular"):assign(players.alice, { silent = true, local_only = true })
check(#env.sent == 0, "a local only role is not sent")
check(env.R("Regular"):has_player(players.alice), "a local only role applies")
check(#env.sent == 0, "the assignment is not sent")
check(env.R("Regular"):has_player(players.alice), "the role applies")
local sd = env.Roles._script_data()
check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending")
check(sd.pending.alice == nil, "the role is not pending")
eq(sd.local_players.alice, { 6 }, "the role is held locally")
env.Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) }
check(env.R("Regular"):has_player(players.alice), "a local only role survives controller updates")
env.Roles.receive_assignment_updates{ env.assignment("alice", { 5 }) }
check(env.R("Regular"):has_player(players.alice), "the role survives controller updates")
env.reset_log()
env.R("Regular"):unassign(players.alice, { local_only = true })
check(not env.R("Regular"):has_player(players.alice) and #env.sent == 0, "removed without sync")
end)
test("jail suppresses every other role", function(env)
test("role:assign of a higher priority role suppresses the rest", function(env)
local players = setup(env)
env.R("Jail"):assign(players.alice, { by_player_name = "<server>", silent = true })
check(eq(Test.names(env.Roles.get_player_roles(players.alice)), { "Jail" }), "only the jail role applies")
eq(env.names(env.Roles.get_player_roles(players.alice)), { "Jail" }, "only the jail role applies")
check(env.R("Moderator"):has_player(players.alice), "a suppressed role is still held")
check(not env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permissions are lost")
check(not env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "the default role is lost")
check(not env.Roles.player_outranks(players.alice, players.bob), "a jailed player no longer outranks")
check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0,
"the event lists only the jail role")
eq(env.events[1].data.assigned, { 7 }, "the event lists the jail role")
check(#env.events[1].data.unassigned == 0, "the suppressed roles are not listed as unassigned")
env.reset_log()
env.R("Jail"):unassign(players.alice, { by_player_name = "<server>", silent = true })
check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }),
eq(Suite.sorted(env.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" },
"unjail restores the roles")
check(#env.events[1].data.assigned == 0 and eq(env.events[1].data.unassigned, { 7 }),
"the unjail event lists only the jail role")
check(#env.events[1].data.assigned == 0, "the restored roles are not listed as assigned")
eq(env.events[1].data.unassigned, { 7 }, "the unjail event lists the jail role")
end)
test("the server can not be assigned roles", function(env)
test("role:assign ignores the server", function(env)
setup(env)
env.R("Moderator"):assign(env.server)
check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op")
end)
return Env.finish()
return Suite.run()
+33 -26
View File
@@ -1,10 +1,11 @@
--[[-- Test environment for module/control.lua
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.
The module specific extension between the shared stubs and the test files: it
creates environments which combine the stubs with a fresh copy of the roles
module and the fixtures below, and hands each test file a suite built around
that factory.
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.
Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite
returned here becomes `...` in each test file.
]]
local shared_root = ... --- @type string
@@ -14,13 +15,8 @@ local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$"))
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 = {
local permission_names = {
"core.admin", -- 0
"exp_scenario.command.kill", -- 1
"exp_scenario.command.jail", -- 2
@@ -36,7 +32,7 @@ local function meta(order, extra)
end
--- Standard fixture: the roles as the controller would send them on initialise
function Env.role_records()
local function role_records()
return {
{ id = 0, name = "Cluster Admin", permissions = { 0 }, meta = meta(1, { short_hand = "SYS" }) },
{ id = 5, name = "Moderator", permissions = { 1, 2, 3, 5 }, meta = meta(2, { color = { r = 0, g = 170, b = 0 } }) },
@@ -47,12 +43,8 @@ function Env.role_records()
}
end
function Env.assignment(name, role_ids)
return { name = name, role_ids = role_ids }
end
--- Create an independent environment with a fresh copy of the roles module
function Env.new()
local function make_env()
local env = Stubs.new()
env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))()
@@ -63,22 +55,37 @@ function Env.new()
return (assert(env.Roles.get_role_by_name(name), "No role named " .. name))
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
--- An assignment record as the controller would send it
function env.assignment(name, role_ids)
return { name = name, role_ids = role_ids }
end
--- Load the standard fixture and the given assignments
function env.initialise(assignments)
env.Roles.initialise{
permission_names = Env.permission_names,
roles = Env.role_records(),
permission_names = permission_names,
roles = role_records(),
assignments = assignments or {},
}
end
--- Save and load the map, as far as the roles module can tell
local stub_save_load = env.save_load
function env.save_load()
stub_save_load()
env.Roles.on_load()
end
return env
end
--- 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
return Framework.new_suite(make_env)
+24 -33
View File
@@ -1,7 +1,6 @@
--- Listing and printing to the players who hold a role
local Env = ...
local Test = Env.Test
local test, check, eq = Test.test, Test.check, Test.eq
--- Tests for listing and printing to the players who hold a role
local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq = Suite.test, Suite.check, Suite.eq
--- alice and dave are moderators with dave offline, zed has never joined
local function setup(env)
@@ -11,47 +10,47 @@ local function setup(env)
dave = env.add_player("dave", 4, false),
}
env.initialise{
Env.assignment("alice", { 5 }),
Env.assignment("bob", { 6 }),
Env.assignment("dave", { 5 }),
Env.assignment("zed", { 5 }),
env.assignment("alice", { 5 }),
env.assignment("bob", { 6 }),
env.assignment("dave", { 5 }),
env.assignment("zed", { 5 }),
}
env.Roles.set_emit_events(true)
return players
end
test("player names include players not on this map", function(env)
test("role:get_player_names includes players not on this map", function(env)
setup(env)
check(eq(Test.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" }),
eq(Suite.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" },
"every player given the role is listed")
end)
test("players are limited to this map and can be filtered", function(env)
setup(env)
local mod = env.R("Moderator")
check(eq(Test.sorted(Test.names(mod:get_players())), { "alice", "dave" }), "players are limited to this map")
check(eq(Test.names(mod:get_players(true)), { "alice" }), "filtered to connected players")
check(eq(Test.names(mod:get_players(false)), { "dave" }), "filtered to offline players")
end)
test("a player holding the role in both lists is counted once", function(env)
test("role:get_player_names counts a player in both lists once", function(env)
local players = setup(env)
env.R("Regular"):assign(players.alice, { silent = true, local_only = true })
env.Roles.receive_assignment_updates{ Env.assignment("alice", { 5, 6 }) }
env.Roles.receive_assignment_updates{ env.assignment("alice", { 5, 6 }) }
local sd = env.Roles._script_data()
check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists")
check(eq(Test.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }), "the player is counted once")
eq(Suite.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }, "the player is counted once")
end)
test("print reaches online holders", function(env)
test("role:get_players is limited to this map and can be filtered", function(env)
setup(env)
local mod = env.R("Moderator")
eq(Suite.sorted(env.names(mod:get_players())), { "alice", "dave" }, "players are limited to this map")
eq(env.names(mod:get_players(true)), { "alice" }, "filtered to connected players")
eq(env.names(mod:get_players(false)), { "dave" }, "filtered to offline players")
end)
test("role:print reaches online holders", function(env)
setup(env)
env.reset_log()
check(env.R("Moderator"):print("hello") == 1, "print returns the number of players reached")
check(#env.printed == 1 and env.printed[1].to == "alice", "only online holders are reached")
end)
test("printing to the higher roles", function(env)
test("role:print over get_higher_roles can repeat", function(env)
local players = setup(env)
env.R("Regular"):assign(players.alice, { silent = true, local_only = true })
env.reset_log()
@@ -62,15 +61,7 @@ test("printing to the higher roles", function(env)
local got = {}
for _, entry in ipairs(env.printed) do got[#got + 1] = entry.to end
-- alice holds two of the roles, so like the legacy system the message can repeat
check(eq(Test.sorted(got), { "alice", "alice", "bob" }), "the online holders of each role are reached")
eq(Suite.sorted(got), { "alice", "alice", "bob" }, "the online holders of each role are reached")
end)
test("deep equality helper", function(env)
check(Test.deep_eq({ a = { 1, 2 }, b = "x" }, { a = { 1, 2 }, b = "x" }), "equal nested tables")
check(not Test.deep_eq({ a = { 1, 2 } }, { a = { 1, 3 } }), "differing nested values")
check(not Test.deep_eq({ a = 1 }, { a = 1, b = 2 }), "extra keys on the right")
check(not Test.deep_eq({ a = 1, b = 2 }, { a = 1 }), "extra keys on the left")
check(Test.deep_eq(env.Roles._script_data().pending, {}), "works against module state")
end)
return Env.finish()
return Suite.run()
+51 -36
View File
@@ -1,28 +1,58 @@
--- Role lookup, decoding, and comparisons
local Env = ...
local Test = Env.Test
local test, check, eq = Test.test, Test.check, Test.eq
--- Tests for the role lookup functions of module/control.lua
local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq = Suite.test, Suite.check, Suite.eq
test("roles are ordered most privileged first", function(env)
test("get_role returns the role with the given clusterio id", function(env)
env.initialise{}
check(eq(Test.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }),
"ordered roles follow their order with deleted roles skipped")
check(#env.Roles.get_roles() == 5, "get_roles returns every role")
local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") }
check(eq(Test.names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first")
end)
test("roles are looked up by id", function(env)
env.initialise{}
check(env.Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id")
check(env.Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles")
check(env.Roles.get_role(6).name == "Regular", "the role is found by id")
check(env.Roles.get_role(env.R("Jail").id) == env.R("Jail"), "lookups return the same object")
check(env.Roles.get_role(9) == nil and env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known")
check(env.Roles.get_default_role() == env.R("Player"), "the default role comes from is_default")
check(env.Roles.get_role(9) == nil, "deleted roles are not known")
end)
test("role records are decoded", function(env)
test("get_role_by_name searches the roles", function(env)
env.initialise{}
check(env.Roles.get_role_by_name("Moderator").id == 5, "the role is found by name")
check(env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known")
end)
test("get_roles returns every role", function(env)
env.initialise{}
check(#env.Roles.get_roles() == 5, "every role is returned with deleted roles skipped")
end)
test("get_ordered_roles returns the most privileged first", function(env)
env.initialise{}
eq(env.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" },
"roles follow their order")
end)
test("sort_roles orders most privileged first", function(env)
env.initialise{}
local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") }
eq(env.names(sorted), { "Cluster Admin", "Regular", "Player" }, "the list is sorted in place")
end)
test("get_default_role follows is_default", function(env)
env.initialise{}
check(env.Roles.get_default_role() == env.R("Player"), "the default role is known")
end)
test("get_higher_roles and get_lower_roles include the role and exclude the default", function(env)
env.initialise{}
eq(Suite.sorted(env.names(env.Roles.get_higher_roles(env.R("Regular")))),
{ "Cluster Admin", "Moderator", "Regular" }, "higher roles")
eq(Suite.sorted(env.names(env.Roles.get_lower_roles(env.R("Regular")))),
{ "Jail", "Regular" }, "lower roles")
end)
test("role:is_higher_than and role:is_lower_than compare on order", function(env)
env.initialise{}
check(env.R("Moderator"):is_higher_than(env.R("Player")), "a lower order is more privileged")
check(env.R("Player"):is_lower_than(env.R("Moderator")), "is_lower_than is the reverse")
check(not env.R("Moderator"):is_higher_than(env.R("Moderator")), "a role is not higher than itself")
end)
test("initialise decodes the role records", function(env)
env.initialise{}
check(env.R("Cluster Admin").short_hand == "SYS", "short hand decoded")
check(env.R("Moderator").short_hand == "Moderator", "short hand falls back to the name")
@@ -30,19 +60,4 @@ test("role records are decoded", function(env)
check(env.R("Jail").priority == 1 and env.R("Jail").block_auto_assign, "priority and block auto assign decoded")
end)
test("roles compare on their order", function(env)
env.initialise{}
check(env.R("Moderator"):is_higher_than(env.R("Player")), "a lower order is more privileged")
check(env.R("Player"):is_lower_than(env.R("Moderator")), "is_lower_than is the reverse")
check(not env.R("Moderator"):is_higher_than(env.R("Moderator")), "a role is not higher than itself")
end)
test("higher and lower roles", function(env)
env.initialise{}
check(eq(Test.sorted(Test.names(env.Roles.get_higher_roles(env.R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }),
"higher roles include the role itself and exclude the default role")
check(eq(Test.sorted(Test.names(env.Roles.get_lower_roles(env.R("Regular")))), { "Jail", "Regular" }),
"lower roles include the role itself and exclude the default role")
end)
return Env.finish()
return Suite.run()
+27 -24
View File
@@ -1,7 +1,6 @@
--- Player role and permission checks
local Env = ...
local Test = Env.Test
local test, check, eq = Test.test, Test.check, Test.eq
--- Tests for the player checks of module/control.lua
local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq = Suite.test, Suite.check, Suite.eq
--- alice is a moderator, bob is a regular, and carol has only the default role
local function setup(env)
@@ -11,42 +10,42 @@ local function setup(env)
carol = env.add_player("carol", 3),
}
env.initialise{
Env.assignment("alice", { 5 }),
Env.assignment("bob", { 6 }),
env.assignment("alice", { 5 }),
env.assignment("bob", { 6 }),
}
return players
end
test("player roles include the default role", function(env)
test("get_player_roles includes the default role", function(env)
local players = setup(env)
check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }),
eq(Suite.sorted(env.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" },
"held roles and the default role")
check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Player" }),
eq(env.names(env.Roles.get_player_roles(players.carol)), { "Player" },
"a player with no roles has the default role")
end)
test("the server is nil or a player with index 0", function(env)
test("get_player_roles treats nil and index 0 as the server", function(env)
setup(env)
check(eq(Test.names(env.Roles.get_player_roles(nil)), { "<server>" }), "nil is the server")
check(eq(Test.names(env.Roles.get_player_roles(env.server)), { "<server>" }), "index 0 is the server")
eq(env.names(env.Roles.get_player_roles(nil)), { "<server>" }, "nil is the server")
eq(env.names(env.Roles.get_player_roles(env.server)), { "<server>" }, "index 0 is the server")
check(env.Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission")
check(not env.R("Player"):has_player(nil), "the server does not have the default role")
end)
test("the highest role", function(env)
test("get_player_highest_role", function(env)
local players = setup(env)
check(env.Roles.get_player_highest_role(players.alice) == env.R("Moderator"), "highest held role")
check(env.Roles.get_player_highest_role(players.alice) == env.R("Moderator"), "the highest held role")
check(env.Roles.get_player_highest_role(players.carol) == env.R("Player"), "the default role when none are held")
end)
test("permission checks", function(env)
test("player_has_permission", function(env)
local players = setup(env)
check(env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "granted by a held role")
check(env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "granted by the default role")
check(not env.Roles.player_has_permission(players.bob, "exp_scenario.command.jail"), "not granted")
end)
test("has any and has all", function(env)
test("player_has_any_permission and player_has_all_permission", function(env)
local players = setup(env)
local jail, kill = "exp_scenario.command.jail", "exp_scenario.command.kill"
check(env.Roles.player_has_any_permission(players.bob, jail, kill), "any passes when one is granted")
@@ -59,17 +58,21 @@ test("has any and has all", function(env)
check(env.Roles.player_has_any_permission(nil, "anything.at.all"), "the server passes any")
end)
test("role permission and player methods", function(env)
local players = setup(env)
check(env.R("Moderator"):has_permission("exp_scenario.command.kill"), "has_permission granted")
check(not env.R("Regular"):has_permission("exp_scenario.command.jail"), "has_permission not granted")
test("role:has_permission", function(env)
setup(env)
check(env.R("Moderator"):has_permission("exp_scenario.command.kill"), "granted")
check(not env.R("Regular"):has_permission("exp_scenario.command.jail"), "not granted")
check(env.R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything")
check(env.R("Moderator"):has_player(players.alice), "has_player for a held role")
check(not env.R("Moderator"):has_player(players.bob), "has_player for a role not held")
end)
test("role:has_player", function(env)
local players = setup(env)
check(env.R("Moderator"):has_player(players.alice), "a held role")
check(not env.R("Moderator"):has_player(players.bob), "a role not held")
check(env.R("Player"):has_player(players.carol), "everyone has the default role")
end)
test("outranks", function(env)
test("player_outranks", function(env)
local players = setup(env)
check(env.Roles.player_outranks(players.alice, players.bob), "a higher role outranks a lower one")
check(not env.Roles.player_outranks(players.bob, players.alice), "a lower role does not outrank a higher one")
@@ -78,4 +81,4 @@ test("outranks", function(env)
check(not env.Roles.player_outranks(players.alice, nil), "nobody outranks the server")
end)
return Env.finish()
return Suite.run()
+46 -39
View File
@@ -1,7 +1,6 @@
--- Initialise semantics and updates from the controller
local Env = ...
local Test = Env.Test
local test, check, eq = Test.test, Test.check, Test.eq
--- Tests for the sync entry points of module/control.lua
local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq, deep_eq = Suite.test, Suite.check, Suite.eq, Suite.deep_eq
--- alice is a moderator and carol has only the default role, both connected
local function setup(env)
@@ -14,8 +13,8 @@ local function setup(env)
env.admin_state[player.name] = state
end)
env.initialise{
Env.assignment("alice", { 5 }),
Env.assignment("zed", { 5, 6 }), -- has never joined this map
env.assignment("alice", { 5 }),
env.assignment("zed", { 5, 6 }), -- has never joined this map
}
env.Roles.set_emit_events(true)
return players
@@ -23,19 +22,35 @@ end
test("initialise applies triggers and raises empty events", function(env)
setup(env)
check(env.admin_state.alice == true and env.admin_state.carol == false,
"triggers run for connected players")
deep_eq(env.admin_state, { alice = true, carol = false }, "triggers run for connected players")
check(#env.events == 2, "the event is raised once per connected player")
check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "the events are empty")
check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing")
end)
test("controller assignments apply and are announced", function(env)
test("initialise is authoritative for pending roles", function(env)
local players = setup(env)
env.R("Moderator"):assign(players.carol)
env.R("Jail"):assign(players.carol, { silent = true, local_only = true })
local sd = env.Roles._script_data()
eq(sd.pending.carol, { 5 }, "the role is pending before initialise")
env.reset_log()
env.initialise{ env.assignment("alice", { 5 }) }
check(sd.pending.carol == nil, "pending roles are cleared")
check(not env.R("Moderator"):has_player(players.carol), "an unconfirmed role is given up")
eq(sd.local_players.carol, { 7 }, "local only roles are kept")
check(#env.sent == 0, "initialise sends nothing")
end)
test("receive_assignment_updates applies controller changes", function(env)
local players = setup(env)
env.reset_log()
env.Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) }
env.Roles.receive_assignment_updates{ env.assignment("carol", { 5 }) }
check(env.R("Moderator"):has_player(players.carol), "the assignment applies")
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event is raised")
check(#env.events == 1, "the event is raised")
eq(env.events[1].data.assigned, { 5 }, "the event carries the role id")
check(env.events[1].data.by_player_index == 0, "controller changes have no by player")
check(#env.printed == 1 and env.printed[1][4] == "<server>", "the change is announced by the server")
check(env.admin_state.carol == true, "triggers follow the assignment")
@@ -43,18 +58,18 @@ test("controller assignments apply and are announced", function(env)
env.reset_log()
env.Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } }
check(not env.R("Moderator"):has_player(players.carol), "a removal applies")
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "the removal raises the event")
eq(env.events[1].data.unassigned, { 5 }, "the removal raises the event")
check(env.admin_state.carol == false, "triggers follow the removal")
end)
test("changes for players not on this map raise nothing", function(env)
test("receive_assignment_updates for players not on this map raises nothing", function(env)
setup(env)
env.reset_log()
env.Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) }
env.Roles.receive_assignment_updates{ env.assignment("zed", { 5 }) }
check(#env.events == 0 and #env.printed == 0, "no event and no announcement")
end)
test("role updates apply to their holders", function(env)
test("receive_role_updates applies to the holders", function(env)
setup(env)
env.reset_log()
env.Roles.receive_role_updates{
@@ -70,33 +85,17 @@ test("role updates apply to their holders", function(env)
check(env.Roles.get_role(6) == nil, "a deleted role is removed")
end)
test("the default role follows is_default", function(env)
test("receive_role_updates moves the default role", function(env)
local players = setup(env)
env.Roles.receive_role_updates{
{ id = 1, name = "Player", permissions = {}, meta = { id = 0, order = 5 } },
{ id = 8, name = "Guest", permissions = {}, meta = { id = 0, order = 7 }, is_default = true },
}
check(env.Roles.get_default_role() == env.R("Guest"), "the new default role is known")
check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Guest" }), "players pick up the new default")
eq(env.names(env.Roles.get_player_roles(players.carol)), { "Guest" }, "players pick up the new default")
end)
test("initialise is authoritative for pending roles", function(env)
local players = setup(env)
env.R("Moderator"):assign(players.carol)
env.R("Jail"):assign(players.carol, { silent = true, local_only = true })
local sd = env.Roles._script_data()
check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise")
env.reset_log()
env.initialise{ Env.assignment("alice", { 5 }) }
check(sd.pending.carol == nil, "pending roles are cleared")
check(not env.R("Moderator"):has_player(players.carol), "an unconfirmed role is given up")
check(eq(sd.local_players.carol, { 7 }), "local only roles are kept")
check(#env.sent == 0, "initialise sends nothing")
end)
test("nothing is sent while emit is disabled", function(env)
test("set_emit_events gates what is sent", function(env)
local players = setup(env)
env.Roles.set_emit_events(false)
env.reset_log()
@@ -105,14 +104,22 @@ test("nothing is sent while emit is disabled", function(env)
check(env.R("Regular"):has_player(players.alice), "the assignment still applies locally")
end)
test("roles are usable after load and triggers run on join", function(env)
setup(env)
env.Roles.on_load()
check(env.R("Moderator"):is_higher_than(env.R("Regular")), "roles are usable after load")
test("on_load restores the state after a save and load", function(env)
local players = setup(env)
env.R("Jail"):assign(players.carol, { silent = true, local_only = true })
env.save_load()
check(env.R("Moderator"):is_higher_than(env.R("Regular")), "role methods survive through the registered metatable")
check(env.R("Moderator"):has_player(players.alice), "synced roles survive")
check(env.R("Jail"):has_player(players.carol), "local roles survive")
check(env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permission checks still answer")
end)
test("on_player_joined_game runs the triggers", function(env)
setup(env)
env.admin_state.alice = nil
env.Roles.on_player_joined_game{ player_index = 1 }
check(env.admin_state.alice ~= nil, "triggers run when a player joins")
end)
return Env.finish()
return Suite.run()
+24 -9
View File
@@ -7,7 +7,7 @@ const fullMeta = new messages.RoleMetaRecord(
7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false,
);
t.test("RoleColor", subtest => {
t.test("class RoleColor", subtest => {
testRoundTripJsonSerialisable(messages.RoleColor, testMatrix(
[0, 255], // r
[0, 128], // g
@@ -17,7 +17,7 @@ t.test("RoleColor", subtest => {
subtest.end();
});
t.test("RoleMetaRecord", subtest => {
t.test("class RoleMetaRecord", subtest => {
testRoundTripJsonSerialisable(messages.RoleMetaRecord, testMatrix(
[7], // id
[3], // order
@@ -34,7 +34,7 @@ t.test("RoleMetaRecord", subtest => {
subtest.end();
});
t.test("RoleRecord", subtest => {
t.test("class RoleRecord", subtest => {
testRoundTripJsonSerialisable(messages.RoleRecord, testMatrix(
[7], // id
["Moderator"], // name
@@ -48,7 +48,7 @@ t.test("RoleRecord", subtest => {
subtest.end();
});
t.test("AssignmentRecord", subtest => {
t.test("class AssignmentRecord", subtest => {
testRoundTripJsonSerialisable(messages.AssignmentRecord, testMatrix(
["alice"], // name
[new Set(), new Set([5, 6])], // roleIds
@@ -59,19 +59,34 @@ t.test("AssignmentRecord", subtest => {
subtest.end();
});
t.test("update events and requests", subtest => {
const record = new messages.RoleRecord(7, "Moderator", ["a.b"], fullMeta, true, 12345);
const assignment = new messages.AssignmentRecord("alice", new Set([5]), 12345);
const sampleRecord = new messages.RoleRecord(7, "Moderator", ["a.b"], fullMeta, true, 12345);
const sampleAssignment = new messages.AssignmentRecord("alice", new Set([5]), 12345);
t.test("class RoleUpdatedEvent", subtest => {
testRoundTripJsonSerialisable(messages.RoleUpdatedEvent, testMatrix(
[[], [record]], // updates
[[], [sampleRecord]], // updates
));
subtest.pass("round trips");
subtest.end();
});
t.test("class AssignmentUpdatedEvent", subtest => {
testRoundTripJsonSerialisable(messages.AssignmentUpdatedEvent, testMatrix(
[[], [assignment]], // updates
[[], [sampleAssignment]], // updates
));
subtest.pass("round trips");
subtest.end();
});
t.test("class RoleMetaUpdateRequest", subtest => {
testRoundTripJsonSerialisable(messages.RoleMetaUpdateRequest, testMatrix(
[new messages.RoleMetaRecord(7, 3), fullMeta], // meta
));
subtest.pass("round trips");
subtest.end();
});
t.test("class AssignmentUpdateRequest", subtest => {
testRoundTripJsonSerialisable(messages.AssignmentUpdateRequest, testMatrix(
["alice"], // name
[[], [5]], // assign
+3 -3
View File
@@ -6,7 +6,7 @@ const { seedRoles, flattenSeedPermissions } = require("../dist/node/seed");
// Importing this defines the exp_scenario permissions the seed grants
require("@expcluster/scenario/dist/node/permissions");
t.test("every seed permission is defined", subtest => {
t.test("seedRoles grant only defined permissions", subtest => {
for (const role of seedRoles) {
for (const permission of flattenSeedPermissions(role)) {
subtest.ok(lib.permissions.has(permission), `${role.name} grants defined permission ${permission}`);
@@ -15,7 +15,7 @@ t.test("every seed permission is defined", subtest => {
subtest.end();
});
t.test("parents exist and their permissions are inherited", subtest => {
t.test("flattenSeedPermissions carries parents into their children", subtest => {
const byName = new Map(seedRoles.map(role => [role.name, role]));
for (const role of seedRoles) {
if (role.parent === undefined) {
@@ -31,7 +31,7 @@ t.test("parents exist and their permissions are inherited", subtest => {
subtest.end();
});
t.test("seed role names are unique with one default and one admin", subtest => {
t.test("seedRoles are unique with one default and one admin", subtest => {
const names = seedRoles.map(role => role.name);
subtest.strictSame(names.length, new Set(names).size, "names are unique");
subtest.strictSame(seedRoles.filter(role => role.isDefault).length, 1, "one default role");
+73 -41
View File
@@ -1,15 +1,30 @@
--[[-- 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.
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 from the
factory the suite was created with, 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 = {
--- 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
--- @param make_env fun(): table Creates the environment given to each test
function Framework.new_suite(make_env)
--- @class Suite
local Suite = {
tests = {}, -- { name, fn } in declaration order
results = {}, -- { name, ok, detail? } accumulated across tests
}
@@ -19,37 +34,61 @@ function Framework.new()
--- 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 }
function Suite.test(name, fn)
Suite.tests[#Suite.tests + 1] = { name = name, fn = fn }
end
--- Record one check within the current test
--- @param ok any Truthy when the check passed
--- 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 Test.check(ok, name, detail)
Test.results[#Test.results + 1] = {
function Suite.fail(name, detail)
Suite.results[#Suite.results + 1] = {
name = current_test and (current_test .. ": " .. name) or name,
ok = not not ok,
ok = false,
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
--- 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
return true
end
--- Check two values or arrays are shallowly equal
function Suite.eq(a, b, name)
local equal
if type(a) ~= "table" or type(b) ~= "table" then
equal = a == b
else
equal = #a == #b
for i = 1, #a do
if a[i] ~= b[i] then equal = false break end
end
end
Suite.check(equal, name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1)))
end
--- Recursive table equality, keys checked from both sides
function Test.deep_eq(a, b)
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 Test.deep_eq(value, b[key]) then return false end
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
@@ -57,38 +96,31 @@ function Framework.new()
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
--- Check two values are equal, comparing tables recursively
function Suite.deep_eq(a, b, name)
Suite.check(deep_equal(a, b), name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1)))
end
--- Sorted copy of an array of strings
function Test.sorted(list)
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 against a fresh environment
--- @param make_env fun(): table
function Test.run(make_env)
for _, test in ipairs(Test.tests) do
--- 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(test.fn, make_env())
if not ok then
Test.check(false, "did not error", tostring(err))
Suite.fail("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())
@@ -96,14 +128,14 @@ function Framework.new()
end
local parts = {}
for index, result in ipairs(Test.results) do
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 Test
return Suite
end
return Framework
+68 -9
View File
@@ -3,8 +3,8 @@ 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`.
Plugins compose these from their own test/lua/env.lua, adding their own stubs
with the extend methods rather than by mutating the tables directly.
]]
local Stubs = {}
@@ -29,6 +29,25 @@ function Stubs.strict(name, tbl, allowed_nil)
})
end
--- Merge extra into target recursively, raising when a value already exists
--- @generic T : table
--- @param target T
--- @param extra table
--- @return T
function Stubs.extend(target, extra)
for key, value in pairs(extra) do
local existing = rawget(target, key)
if existing == nil then
rawset(target, key, value)
elseif type(existing) == "table" and type(value) == "table" then
Stubs.extend(existing, value)
else
error("Stub already implements: " .. tostring(key), 2)
end
end
return target
end
--- Create an independent set of stubs, installed as the lua globals
function Stubs.new()
local stubs = {
@@ -39,7 +58,8 @@ function Stubs.new()
}
local next_event_id = 100
script = Stubs.strict("script", {
local registered_metatables = {} --- @type table<table, true>
script = Stubs.strict("LuaBootstrap", {
generate_event_name = function()
next_event_id = next_event_id + 1
return next_event_id
@@ -47,7 +67,9 @@ function Stubs.new()
raise_event = function(id, data)
stubs.events[#stubs.events + 1] = { id = id, data = data }
end,
register_metatable = function() end,
register_metatable = function(_, metatable)
registered_metatables[metatable] = true
end,
})
defines = Stubs.strict("defines", {
@@ -58,7 +80,7 @@ function Stubs.new()
})
local players_by_name, players_by_index, connected = {}, {}, {}
game = Stubs.strict("game", {
game = Stubs.strict("LuaGameScript", {
tick = 1,
players = players_by_name,
connected_players = connected,
@@ -87,10 +109,10 @@ function Stubs.new()
end
--- A player object which represents the server
stubs.server = Stubs.strict("server player", { index = 0, name = "<server>" })
stubs.server = Stubs.strict("LuaPlayer <server>", { index = 0, name = "<server>" })
--- Modules resolved by the stubbed require, extend before loading the module
stubs.requires = {
--- Modules resolved by the stubbed require, add to them with extend_requires
local requires = {
["modules/clusterio/api"] = Stubs.strict("clusterio api", {
send_json = function(channel, data)
stubs.sent[#stubs.sent + 1] = { channel = channel, data = data }
@@ -104,7 +126,44 @@ function Stubs.new()
}),
}
require = function(name)
return assert(stubs.requires[name], "Unexpected require: " .. name)
return assert(rawget(requires, name), "Unexpected require: " .. name)
end
--- Add modules or properties to the stubbed require
function stubs.extend_requires(extra) return Stubs.extend(requires, extra) end
--- Add properties to the script, game, and defines globals
function stubs.extend_script(extra) return Stubs.extend(script, extra) end
function stubs.extend_game(extra) return Stubs.extend(game, extra) end
function stubs.extend_defines(extra) return Stubs.extend(defines, extra) end
--- Copy a value the way factorio saves script data: functions are refused
--- and only metatables registered with script.register_metatable survive
local function save_load_copy(value, copies)
if type(value) == "function" then
error("Functions can not be stored in script data", 0)
end
if type(value) ~= "table" then return value end
if copies[value] then return copies[value] end
local copy = {}
copies[value] = copy
for key, entry in pairs(value) do
copy[save_load_copy(key, copies)] = save_load_copy(entry, copies)
end
local metatable = getmetatable(value)
if metatable ~= nil and registered_metatables[metatable] then
setmetatable(copy, metatable)
end
return copy
end
--- Replace the script data with a copy of itself as if the map was saved
--- and loaded, the module's on_load handler should be called afterwards
function stubs.save_load()
local compat = requires["modules/clusterio/compat"]
compat.script_data = save_load_copy(compat.script_data, {})
end
--- Forget everything recorded so far