diff --git a/exp_roles/package.json b/exp_roles/package.json index d6530a11..1e462135 100644 --- a/exp_roles/package.json +++ b/exp_roles/package.json @@ -7,7 +7,8 @@ "repository": "explosivegaming/ExpCluster", "main": "dist/node/index.js", "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": { "node": ">=18" @@ -24,6 +25,7 @@ "@clusterio/host": "workspace:^", "@clusterio/lib": "workspace:^", "@clusterio/web_ui": "workspace:^", + "@expcluster/scenario": "workspace:^", "@types/node": "catalog:", "@types/react": "catalog:", "antd": "catalog:", @@ -32,6 +34,8 @@ "react-router-dom": "catalog:", "typescript": "catalog:", "webpack": "catalog:", + "fengari": "^0.1.5", + "tap": "^21.1.0", "webpack-cli": "catalog:", "webpack-merge": "catalog:" }, diff --git a/exp_roles/test/helpers/lua.js b/exp_roles/test/helpers/lua.js new file mode 100644 index 00000000..8f8dddde --- /dev/null +++ b/exp_roles/test/helpers/lua.js @@ -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 }; diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/lua/assignment.lua new file mode 100644 index 00000000..cba6b7cc --- /dev/null +++ b/exp_roles/test/lua/assignment.lua @@ -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 = "", 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 = "", 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) diff --git a/exp_roles/test/lua/env.lua b/exp_roles/test/lua/env.lua new file mode 100644 index 00000000..33bf7b40 --- /dev/null +++ b/exp_roles/test/lua/env.lua @@ -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 = "" }, { + __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 diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/lua/holders.lua new file mode 100644 index 00000000..bb345ddf --- /dev/null +++ b/exp_roles/test/lua/holders.lua @@ -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) diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/lua/lookup.lua new file mode 100644 index 00000000..b32bf3b0 --- /dev/null +++ b/exp_roles/test/lua/lookup.lua @@ -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) diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/lua/players.lua new file mode 100644 index 00000000..c77bb349 --- /dev/null +++ b/exp_roles/test/lua/players.lua @@ -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)), { "" }), "nil is the server") +check(eq(names(Roles.get_player_roles(env.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) diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/lua/sync.lua new file mode 100644 index 00000000..dc84edf8 --- /dev/null +++ b/exp_roles/test/lua/sync.lua @@ -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] == "", "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) diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js new file mode 100644 index 00000000..ff1c774a --- /dev/null +++ b/exp_roles/test/messages.test.js @@ -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(); +}); diff --git a/exp_roles/test/module.test.js b/exp_roles/test/module.test.js new file mode 100644 index 00000000..9bca4e1a --- /dev/null +++ b/exp_roles/test/module.test.js @@ -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)); +} diff --git a/exp_roles/test/seed.test.js b/exp_roles/test/seed.test.js new file mode 100644 index 00000000..60f4c2f2 --- /dev/null +++ b/exp_roles/test/seed.test.js @@ -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(); +});