Add a test suite for the roles plugin

Run with `pnpm --filter @expcluster/roles test`, using tap as the runner.

The lua module is exercised for real: each test file runs in its own lua
state, created on first use by the helper, with the factorio surface and the
clusterio modules stubbed. The environment is passed to the chunk as an
argument so no globals are involved. Covers lookups and comparisons, the
permission checks, assignment with confirmation, rejection and local only
roles, jail suppression, the sync entry points, and the holder listings,
including a player holding a role in both the synced and the local list.

The javascript side covers the message records round tripping through their
schemas, the indexed permission encoding, and the seed: every permission it
grants must be defined by exp_scenario, and every parent must exist and have
its permissions carried into its children.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bbassie
2026-08-20 22:02:51 +00:00
co-authored by Claude Fable 5
parent 8a15745cf8
commit e79822b26e
11 changed files with 674 additions and 1 deletions
+5 -1
View File
@@ -7,7 +7,8 @@
"repository": "explosivegaming/ExpCluster", "repository": "explosivegaming/ExpCluster",
"main": "dist/node/index.js", "main": "dist/node/index.js",
"scripts": { "scripts": {
"prepare": "tsc --build && webpack-cli --env production" "prepare": "tsc --build && webpack-cli --env production",
"test": "tap --disable-coverage --allow-empty-coverage test/*.test.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -24,6 +25,7 @@
"@clusterio/host": "workspace:^", "@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^", "@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^", "@clusterio/web_ui": "workspace:^",
"@expcluster/scenario": "workspace:^",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/react": "catalog:", "@types/react": "catalog:",
"antd": "catalog:", "antd": "catalog:",
@@ -32,6 +34,8 @@
"react-router-dom": "catalog:", "react-router-dom": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"webpack": "catalog:", "webpack": "catalog:",
"fengari": "^0.1.5",
"tap": "^21.1.0",
"webpack-cli": "catalog:", "webpack-cli": "catalog:",
"webpack-merge": "catalog:" "webpack-merge": "catalog:"
}, },
+62
View File
@@ -0,0 +1,62 @@
"use strict";
const path = require("node:path");
const fs = require("node:fs");
const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari");
const moduleRoot = path.join(__dirname, "..", "..", "module");
const luaRoot = path.join(__dirname, "..", "lua");
/**
* 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 environment. The file receives the environment helpers
* from env.lua and must return an array of { name, ok, detail? } results,
* encoded as JSON.
*
* @param {string} name - File in test/lua to run, such as "lookup.lua".
* @returns {{ name: string, ok: boolean, detail?: string }[]}
*/
function runLuaTests(name) {
const L = lauxlib.luaL_newstate();
lualib.luaL_openlibs(L);
// Each file is run as a chunk taking one argument and returning one value
const load = (file, chunkName) => {
const code = fs.readFileSync(file);
const status = lauxlib.luaL_loadbuffer(L, code, code.length, to_luastring(`@${chunkName}`));
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)));
}
};
// The environment stubs factorio and loads module/control.lua from disk
load(path.join(luaRoot, "env.lua"), "test/lua/env.lua");
lua.lua_pushstring(L, to_luastring(moduleRoot));
call();
// The environment stays on the stack and is passed to the test chunk
load(path.join(luaRoot, name), `test/lua/${name}`);
lua.lua_insert(L, -2);
call();
const json = to_jsstring(lua.lua_tostring(L, -1));
return JSON.parse(json);
}
/** Report lua results through a tap test object. */
function reportLuaTests(t, name) {
const results = runLuaTests(name);
t.ok(results.length > 0, `${name} produced results`);
for (const result of results) {
t.ok(result.ok, result.name, result.detail ? { detail: result.detail } : {});
}
t.end();
}
module.exports = { runLuaTests, reportLuaTests };
+93
View File
@@ -0,0 +1,93 @@
--- Assignment through role methods, confirmation, and rejection
local Env = ...
local env = Env.new()
local check, eq, R = env.check, env.eq, env.R
local Roles = env.Roles
local sd = Roles._script_data()
local alice = env.add_player("alice", 1)
local bob = env.add_player("bob", 2)
env.initialise{
Env.assignment("alice", { 5 }),
Env.assignment("bob", { 6 }),
}
Roles.set_emit_events(true)
env.reset_log()
-- Assign with sync
R("Moderator"):assign(bob, { by_player_name = "alice" })
check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "assignment is sent to the controller")
check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "assignment payload")
check(R("Moderator"):has_player(bob), "the role applies before confirmation")
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "event carries the assigned role id")
check(env.events[1].data.by_player_index == 1, "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", "assign sound")
check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "held locally and pending")
env.reset_log()
R("Moderator"):assign(bob)
check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op")
-- Controller confirms
env.reset_log()
Roles.receive_assignment_updates{ Env.assignment("bob", { 6, 5 }) }
check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing")
check(sd.local_players.bob == nil and sd.pending.bob == nil, "a confirmed role is no longer held locally")
check(R("Moderator"):has_player(bob), "the role is still held after confirmation")
-- Unassign a synced role
env.reset_log()
R("Moderator"):unassign(bob, { by_player_name = "alice", silent = true })
check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 5 }), "unassignment is sent to the controller")
check(not R("Moderator"):has_player(bob), "the role is removed locally")
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "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", "unassign sound")
Roles.receive_assignment_updates{ Env.assignment("bob", { 6 }) }
-- Rejection rolls back
env.reset_log()
R("Jail"):assign(alice)
check(R("Jail"):has_player(alice), "the role applies before rejection")
env.reset_log()
Roles.reject_assignment{ name = "alice", role_ids = { 7 } }
check(not R("Jail"):has_player(alice), "a 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(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state")
-- Local only roles
env.reset_log()
R("Regular"):assign(alice, { silent = true, local_only = true })
check(#env.sent == 0, "a local only role is not sent")
check(R("Regular"):has_player(alice), "a local only role applies")
check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending")
Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) }
check(R("Regular"):has_player(alice), "a local only role survives controller updates")
env.reset_log()
R("Regular"):unassign(alice, { local_only = true })
check(not R("Regular"):has_player(alice) and #env.sent == 0, "a local only role is removed without sync")
-- Jail priority
env.reset_log()
R("Jail"):assign(alice, { by_player_name = "<server>", silent = true })
check(eq(env.names(Roles.get_player_roles(alice)), { "Jail" }), "jail suppresses every other role")
check(R("Moderator"):has_player(alice), "a suppressed role is still held")
check(not Roles.player_has_permission(alice, "exp_scenario.command.kill"), "a jailed player loses permissions")
check(not Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "a jailed player loses the default role")
check(not Roles.player_outranks(alice, bob), "a jailed player no longer outranks")
check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0,
"the jail event lists only the jail role")
env.reset_log()
R("Jail"):unassign(alice, { by_player_name = "<server>", silent = true })
check(eq(env.sorted(env.names(Roles.get_player_roles(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")
-- The server can not be assigned roles
env.reset_log()
R("Moderator"):assign(env.server)
check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op")
return Env.results_json(env)
+193
View File
@@ -0,0 +1,193 @@
--[[-- 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.
Run as a chunk by test/helpers/lua.js, receiving the module directory and
returning this table, which is in turn passed to each test chunk.
]]
local module_root = ... --- @type string
local Env = {}
--- Standard fixture: permission names sent once and referenced by zero based index
Env.permission_names = {
"core.admin", -- 0
"exp_scenario.command.kill", -- 1
"exp_scenario.command.jail", -- 2
"exp_scenario.player.admin", -- 3
"exp_scenario.gui.readme", -- 4
"exp_scenario.command.assign_role", -- 5
}
local function meta(order, extra)
local m = { id = 0, order = order }
for k, v in pairs(extra or {}) do m[k] = v end
return m
end
--- Standard fixture: the roles as the controller would send them on initialise
function Env.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 } }) },
{ id = 6, name = "Regular", permissions = { 1 }, meta = meta(3) },
{ id = 7, name = "Jail", permissions = {}, meta = meta(4, { priority = 1, block_auto_assign = true }) },
{ id = 1, name = "Player", permissions = { 4 }, meta = meta(5), is_default = true },
{ id = 9, name = "Gone", permissions = {}, meta = meta(6), is_deleted = true },
}
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 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 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.on_server_startup()
--- Get a role by name, failing the test file when it does not exist
function env.R(name)
return (assert(env.Roles.get_role_by_name(name), "No role named " .. name))
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(),
assignments = assignments or {},
}
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, ",") .. "]"
end
return Env
+47
View File
@@ -0,0 +1,47 @@
--- Listing and printing to the players who hold a role
local Env = ...
local env = Env.new()
local check, eq, R = env.check, env.eq, env.R
local Roles = env.Roles
local alice = env.add_player("alice", 1)
env.add_player("bob", 2)
env.add_player("dave", 4, false) -- offline
env.initialise{
Env.assignment("alice", { 5 }),
Env.assignment("bob", { 6 }),
Env.assignment("dave", { 5 }),
Env.assignment("zed", { 5 }), -- has never joined this map
}
Roles.set_emit_events(true)
local mod = R("Moderator")
check(eq(env.sorted(mod:get_player_names()), { "alice", "dave", "zed" }),
"player names include players not on this map")
check(eq(env.sorted(env.names(mod:get_players())), { "alice", "dave" }),
"players are limited to this map")
check(eq(env.names(mod:get_players(true)), { "alice" }), "players can be filtered by connected state")
check(eq(env.names(mod:get_players(false)), { "dave" }), "players can be filtered to offline")
-- A player holding the role in both the synced and the local list counts once
R("Regular"):assign(alice, { silent = true, local_only = true })
Roles.receive_assignment_updates{ Env.assignment("alice", { 5, 6 }) }
local sd = Roles._script_data()
check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists")
check(eq(env.sorted(R("Regular"):get_player_names()), { "alice", "bob" }),
"a player in both lists is counted once")
env.reset_log()
check(mod:print("hello") == 1, "print returns the number of players reached")
check(#env.printed == 1 and env.printed[1].to == "alice", "print reaches only online holders")
env.reset_log()
for _, role in ipairs(Roles.get_higher_roles(R("Regular"))) do
role:print("hello")
end
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(env.sorted(got), { "alice", "alice", "bob" }), "printing to the higher roles reaches their online holders")
return Env.results_json(env)
+36
View File
@@ -0,0 +1,36 @@
--- Role lookup, decoding, and comparisons
local Env = ...
local env = Env.new()
local check, eq, names, R = env.check, env.eq, env.names, env.R
local Roles = env.Roles
env.initialise{}
check(eq(names(Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }),
"ordered roles are most privileged first with deleted roles skipped")
check(#Roles.get_roles() == 5, "get_roles returns every role")
check(Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id")
check(Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles")
check(Roles.get_role(R("Jail").id) == R("Jail"), "lookups return the same object")
check(Roles.get_role(9) == nil and Roles.get_role_by_name("Gone") == nil, "deleted roles are not known")
check(Roles.get_default_role() == R("Player"), "the default role comes from is_default")
check(R("Moderator").short_hand == "Mod" or R("Moderator").short_hand == "Moderator",
"short hand falls back to the name")
check(R("Cluster Admin").short_hand == "SYS", "short hand decoded")
check(R("Moderator").color.g == 170, "color decoded")
check(R("Jail").priority == 1 and R("Jail").block_auto_assign, "priority and block auto assign decoded")
check(R("Moderator"):is_higher_than(R("Player")), "roles compare on their order")
check(R("Player"):is_lower_than(R("Moderator")), "is_lower_than is the reverse")
check(not R("Moderator"):is_higher_than(R("Moderator")), "a role is not higher than itself")
check(eq(env.sorted(names(Roles.get_higher_roles(R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }),
"higher roles include the role itself and exclude the default role")
check(eq(env.sorted(names(Roles.get_lower_roles(R("Regular")))), { "Jail", "Regular" }),
"lower roles include the role itself and exclude the default role")
local sorted = Roles.sort_roles{ R("Player"), R("Cluster Admin"), R("Regular") }
check(eq(names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first")
return Env.results_json(env)
+56
View File
@@ -0,0 +1,56 @@
--- Player role and permission checks
local Env = ...
local env = Env.new()
local check, eq, names, R = env.check, env.eq, env.names, env.R
local Roles = env.Roles
local alice = env.add_player("alice", 1) -- moderator
local bob = env.add_player("bob", 2) -- regular
local carol = env.add_player("carol", 3) -- only the default role
env.initialise{
Env.assignment("alice", { 5 }),
Env.assignment("bob", { 6 }),
}
check(eq(env.sorted(names(Roles.get_player_roles(alice))), { "Moderator", "Player" }),
"player roles include the default role")
check(eq(names(Roles.get_player_roles(carol)), { "Player" }), "a player with no roles has the default role")
check(eq(names(Roles.get_player_roles(nil)), { "<server>" }), "nil is the server")
check(eq(names(Roles.get_player_roles(env.server)), { "<server>" }), "a player with index 0 is the server")
check(Roles.get_player_highest_role(alice) == R("Moderator"), "highest role")
check(Roles.get_player_highest_role(carol) == R("Player"), "highest role is the default role when none are held")
check(Roles.player_has_permission(alice, "exp_scenario.command.kill"), "permission through a held role")
check(Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "permission through the default role")
check(not Roles.player_has_permission(bob, "exp_scenario.command.jail"), "permission not granted")
check(Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission")
check(Roles.player_has_permission(env.server, "anything.at.all"), "an index 0 player has every permission")
check(Roles.player_has_any_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.kill"),
"has any passes when one permission is granted")
check(not Roles.player_has_any_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.assign_role"),
"has any fails when none are granted")
check(not Roles.player_has_any_permission(bob), "has any with no permissions is false")
check(Roles.player_has_all_permission(alice, "exp_scenario.command.jail", "exp_scenario.command.kill"),
"has all passes when every permission is granted")
check(not Roles.player_has_all_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.kill"),
"has all fails when one is missing")
check(Roles.player_has_all_permission(bob), "has all with no permissions is true")
check(Roles.player_has_any_permission(nil, "anything.at.all"), "the server passes has any")
check(R("Moderator"):has_permission("exp_scenario.command.kill"), "role has_permission")
check(not R("Regular"):has_permission("exp_scenario.command.jail"), "role has_permission not granted")
check(R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything")
check(R("Moderator"):has_player(alice), "has_player for a held role")
check(not R("Moderator"):has_player(bob), "has_player for a role not held")
check(R("Player"):has_player(carol), "everyone has the default role")
check(not R("Player"):has_player(nil), "the server does not have the default role")
check(Roles.player_outranks(alice, bob), "a higher role outranks a lower one")
check(not Roles.player_outranks(bob, alice), "a lower role does not outrank a higher one")
check(not Roles.player_outranks(alice, alice), "nobody outranks themselves")
check(Roles.player_outranks(nil, alice), "the server outranks everyone")
check(not Roles.player_outranks(alice, nil), "nobody outranks the server")
return Env.results_json(env)
+91
View File
@@ -0,0 +1,91 @@
--- Initialise semantics and updates from the controller
local Env = ...
local env = Env.new()
local check, eq, R = env.check, env.eq, env.R
local Roles = env.Roles
local sd = Roles._script_data()
local alice = env.add_player("alice", 1)
local carol = env.add_player("carol", 3)
local admin_state = {}
Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state)
admin_state[player.name] = state
end)
env.initialise{
Env.assignment("alice", { 5 }),
Env.assignment("zed", { 5, 6 }), -- has never joined this map
}
Roles.set_emit_events(true)
check(admin_state.alice == true and admin_state.carol == false, "triggers run for connected players on initialise")
check(#env.events == 2, "the roles changed event is raised once per connected player on initialise")
check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "initialise events are empty")
check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing")
-- Changes to the roles a player holds, made on the controller
env.reset_log()
Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) }
check(R("Moderator"):has_player(carol), "a controller assignment applies")
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "a controller assignment raises the event")
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>", "a controller assignment is announced by the server")
check(admin_state.carol == true, "triggers follow controller assignments")
env.reset_log()
Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } }
check(not R("Moderator"):has_player(carol), "a controller removal applies")
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "a controller removal raises the event")
check(admin_state.carol == false, "triggers follow controller removals")
env.reset_log()
Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) }
check(#env.events == 0 and #env.printed == 0, "changes for players not on this map raise nothing")
-- Changes to the roles themselves
env.reset_log()
Roles.receive_role_updates{
{ id = 6, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } },
}
check(R("Regular"):has_permission("exp_scenario.command.jail"), "a role permission change applies")
check(#env.events == 2, "a role change raises for every connected player")
check(#env.events[1].data.assigned == 0, "role change events are empty")
env.reset_log()
Roles.receive_role_updates{ { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true } }
check(Roles.get_role(6) == nil, "a deleted role is removed")
env.reset_log()
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(Roles.get_default_role() == R("Guest"), "the default role follows is_default")
check(eq(env.names(Roles.get_player_roles(carol)), { "Guest" }), "players pick up the new default role")
-- Initialise is authoritative for pending roles
R("Moderator"):assign(carol)
R("Jail"):assign(carol, { silent = true, local_only = true })
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, "initialise clears pending roles")
check(not R("Moderator"):has_player(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")
-- Emit updates gate and load
Roles.set_emit_events(false)
env.reset_log()
R("Regular"):assign(alice)
check(#env.sent == 0, "nothing is sent while emit is disabled")
Roles.on_load()
check(R("Moderator"):is_higher_than(R("Regular")), "roles are usable after load")
-- Triggers on join
admin_state.alice = nil
Roles.on_player_joined_game{ player_index = 1 }
check(admin_state.alice ~= nil, "triggers run when a player joins")
return Env.results_json(env)
+43
View File
@@ -0,0 +1,43 @@
"use strict";
const t = require("tap");
const { Value } = require("@sinclair/typebox/value");
const messages = require("../dist/node/messages");
function roundTrip(subtest, Record, record) {
const json = record.toJSON();
subtest.ok(Value.Check(Record.jsonSchema, json), "json matches the schema");
subtest.strictSame(Record.fromJSON(json), record, "the record round trips");
}
t.test("RoleMetaRecord", subtest => {
roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord(7, 3));
roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord(
7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false,
));
subtest.end();
});
t.test("RoleRecord", subtest => {
roundTrip(subtest, messages.RoleRecord, new messages.RoleRecord(
7, "Moderator", ["exp_scenario.command.kill"], new messages.RoleMetaRecord(7, 3), true, 12345,
));
subtest.end();
});
t.test("AssignmentRecord", subtest => {
roundTrip(subtest, messages.AssignmentRecord, new messages.AssignmentRecord("alice", new Set([5, 6]), 12345));
subtest.end();
});
t.test("encodeRolesForLua sends each permission name once", subtest => {
const meta = id => new messages.RoleMetaRecord(id, id);
const encoded = messages.encodeRolesForLua([
new messages.RoleRecord(1, "A", ["p.one", "p.two"], meta(1)),
new messages.RoleRecord(2, "B", ["p.two", "p.three"], meta(2)),
]);
subtest.strictSame(encoded.permission_names, ["p.one", "p.two", "p.three"], "names are deduplicated");
subtest.strictSame(encoded.roles[0].permissions, [0, 1], "indexes are zero based");
subtest.strictSame(encoded.roles[1].permissions, [1, 2], "shared names reuse their index");
subtest.end();
});
+8
View File
@@ -0,0 +1,8 @@
"use strict";
const t = require("tap");
const { reportLuaTests } = require("./helpers/lua");
// Each file runs in its own lua state with a fresh copy of the module
for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) {
t.test(file, subtest => reportLuaTests(subtest, file));
}
+40
View File
@@ -0,0 +1,40 @@
"use strict";
const t = require("tap");
const lib = require("@clusterio/lib");
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 => {
for (const role of seedRoles) {
for (const permission of flattenSeedPermissions(role)) {
subtest.ok(lib.permissions.has(permission), `${role.name} grants defined permission ${permission}`);
}
}
subtest.end();
});
t.test("parents exist and their permissions are inherited", subtest => {
const byName = new Map(seedRoles.map(role => [role.name, role]));
for (const role of seedRoles) {
if (role.parent === undefined) {
continue;
}
const parent = byName.get(role.parent);
subtest.ok(parent, `${role.name} has parent ${role.parent}`);
const flattened = flattenSeedPermissions(role);
for (const permission of flattenSeedPermissions(parent)) {
subtest.ok(flattened.has(permission), `${role.name} inherits ${permission}`);
}
}
subtest.end();
});
t.test("seed role names 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");
subtest.strictSame(seedRoles.filter(role => role.isAdmin).length, 1, "one admin role");
subtest.end();
});