diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js index 661ba735..8b47e5cb 100644 --- a/exp_roles/test/controller.test.js +++ b/exp_roles/test/controller.test.js @@ -52,7 +52,7 @@ async function startPlugin(t2, { roles = [] } = {}) { const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions)); t.test("class ControllerPlugin", t2 => { - t2.test("init and sweepRoleMeta manage the role properties", async t3 => { + t2.test(".init() and .sweepRoleMeta() manage the role properties", async t3 => { const { plugin } = await startPlugin(t3, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); t3.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first"); @@ -64,7 +64,7 @@ t.test("class ControllerPlugin", t2 => { t3.ok(plugin.roleMeta.get(5), "properties with a role are kept"); }); - t2.test("buildRoleRecord combines the role with its properties", async t3 => { + t2.test(".buildRoleRecord() combines the role with its properties", async t3 => { const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator", ["exp_scenario.command.kill"])], }); @@ -78,7 +78,7 @@ t.test("class ControllerPlugin", t2 => { t3.ok(plugin.buildRoleRecord(controller.roles.get(1)).isDefault, "the default role is marked"); }); - t2.test("rolesUpdated and roleMetaUpdated broadcast to subscribers", async t3 => { + t2.test(".rolesUpdated() and .roleMetaUpdated() broadcast to subscribers", async t3 => { const { plugin, controller, state } = await startPlugin(t3, { roles: [role(1, "Player")] }); state.broadcasts.length = 0; @@ -102,7 +102,7 @@ t.test("class ControllerPlugin", t2 => { ); }); - t2.test("handleRoleSubscription replays only newer records", async t3 => { + t2.test(".handleRoleSubscription() replays only newer records", async t3 => { const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player")] }); controller.roles.set(role(5, "Moderator")); const updatedAtMs = plugin.buildRoleRecord(controller.roles.get(5)).updatedAtMs; @@ -113,7 +113,7 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame(none, null, "nothing is replayed when up to date"); }); - t2.test("listAssignmentRecords mirrors users without the default role", async t3 => { + t2.test(".listAssignmentRecords() mirrors users without the default role", async t3 => { const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] }); controller.users.createUser("alice").set("roleIds", new Set([lib.Role.DefaultPlayerRoleId, 5])); controller.users.createUser("bob"); @@ -124,7 +124,7 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame([...records[0].roleIds], [5], "the default role is not listed"); }); - t2.test("handleAssignmentUpdateRequest validates the user and roles", async t3 => { + t2.test(".handleAssignmentUpdateRequest() validates the user and roles", async t3 => { const { plugin, controller, state } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")], }); @@ -144,7 +144,7 @@ t.test("class ControllerPlugin", t2 => { t3.ok(state.permissionUpdates.includes("alice"), "permission changes are pushed to the user"); }); - t2.test("applyAutoAssign and onPlayerEvent grant roles from online time", async t3 => { + t2.test(".applyAutoAssign() and .onPlayerEvent() grant roles from online time", async t3 => { const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(6, "Regular"), role(7, "Jail")], }); @@ -173,7 +173,32 @@ t.test("class ControllerPlugin", t2 => { t3.strictSame(applied.length, 1, "other player events do not"); }); - t2.test("handleSeedRolesRequest creates the roles and reuses them by name", async t3 => { + t2.test(".handleRoleMetaUpdateRequest() validates the role", async t3 => { + const { plugin } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] }); + + await t3.rejects( + plugin.handleRoleMetaUpdateRequest(new messages.RoleMetaUpdateRequest(new messages.RoleMetaRecord(99, 1))), + { message: /does not exist/ }, "properties for an unknown role are refused", + ); + + await plugin.handleRoleMetaUpdateRequest(new messages.RoleMetaUpdateRequest( + new messages.RoleMetaRecord(5, 2, 0, "Mod"), + )); + t3.strictSame(plugin.roleMeta.get(5).shortHand, "Mod", "the properties are stored"); + }); + + t2.test(".handleAssignmentSubscription() replays only newer records", async t3 => { + const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] }); + controller.users.createUser("alice").set("roleIds", new Set([5])); + const updatedAtMs = plugin.listAssignmentRecords()[0].updatedAtMs; + + const all = await plugin.handleAssignmentSubscription({ lastRequestTimeMs: 0 }); + t3.strictSame(all.updates.length, 1, "everything is replayed from the start"); + const none = await plugin.handleAssignmentSubscription({ lastRequestTimeMs: updatedAtMs }); + t3.strictSame(none, null, "nothing is replayed when up to date"); + }); + + t2.test(".handleSeedRolesRequest() creates the roles and reuses them by name", async t3 => { const { plugin, controller } = await startPlugin(t3, { roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")], }); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js index 1b1e2d9b..cb3db308 100644 --- a/exp_roles/test/instance.test.js +++ b/exp_roles/test/instance.test.js @@ -1,14 +1,32 @@ "use strict"; const t = require("tap"); const lib = require("@clusterio/lib"); +const { Instance } = require("@clusterio/host"); const { InstancePlugin } = require("../dist/node/instance"); +const { plugin: pluginDeclaration } = require("../dist/node/index"); const messages = require("../dist/node/messages"); +// The instance validates message classes against the link registry, and the +// plugin's config fields must be defined before an InstanceConfig can set them +lib.Link.register(messages.RoleUpdatedEvent); +lib.Link.register(messages.AssignmentUpdatedEvent); +lib.Link.register(messages.RoleListRequest); +lib.Link.register(messages.AssignmentListRequest); +lib.Link.register(messages.AssignmentUpdateRequest); +lib.addPluginConfigFields([pluginDeclaration]); + const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; -// Subscribing validates event names against the link registry -lib.Link.register(messages.RoleUpdatedEvent); -lib.Link.register(messages.AssignmentUpdatedEvent); +class TestConnector extends lib.BaseConnector { + constructor() { + super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 })); + this.valid = true; + this.connected = true; + this.hasSession = true; + } + + send() {} +} const sampleRoles = () => [ new messages.RoleRecord(1, "Player", ["exp_scenario.gui.readme"], new messages.RoleMetaRecord(1, 2), true), @@ -16,30 +34,40 @@ const sampleRoles = () => [ ]; const sampleAssignments = () => [new messages.AssignmentRecord("alice", new Set([5]))]; -/** Build a plugin around a fake instance, started on first use by each test. */ +/** Build a plugin around a real instance with spies on what leaves it, started on first use by each test. */ async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles() } = {}) { + const instanceConfig = new lib.InstanceConfig("host"); + instanceConfig.set("instance.id", 1); + instanceConfig.set("instance.name", "test"); + instanceConfig.set("exp_roles.sync_mode", syncMode); + + const instance = new Instance( + { assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig + ); + + // Spies which record the messages and commands leaving the instance const state = { sent: [], rcons: [], warnings: [], failNext: null }; - const instance = { - logger, - config: { get: field => (field === "exp_roles.sync_mode" ? syncMode : undefined) }, + instance.server = { handle: () => {}, - server: { handle: () => {} }, - sendTo: async (dst, request) => { - state.sent.push(request); - if (state.failNext) { - const error = state.failNext; - state.failNext = null; - throw error; - } - if (request instanceof messages.RoleListRequest) { - return roles; - } - if (request instanceof messages.AssignmentListRequest) { - return sampleAssignments(); - } - return undefined; + sendRcon: async command => { + state.rcons.push(command); + return ""; }, - sendRcon: async command => state.rcons.push(command), + }; + instance.sendTo = async (dst, request) => { + state.sent.push(request); + if (state.failNext) { + const error = state.failNext; + state.failNext = null; + throw error; + } + if (request instanceof messages.RoleListRequest) { + return roles; + } + if (request instanceof messages.AssignmentListRequest) { + return sampleAssignments(); + } + return undefined; }; const plugin = new InstancePlugin({ name: "exp_roles" }, instance, {}); @@ -55,92 +83,103 @@ function decodeRcon(command) { } t.test("class InstancePlugin", t2 => { - t2.test("onStart subscribes and initialises the lua module", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); + t2.test(".onStart() subscribes and initialises the lua module", async t3 => { + const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" }); await plugin.onStart(); const subscribed = state.sent.filter(request => request instanceof lib.SubscriptionRequest); - t2.strictSame(subscribed.length, 2, "roles and assignments are subscribed to"); - t2.ok(state.sent.some(request => request instanceof messages.RoleListRequest), "the roles are requested"); - t2.ok(state.sent.some(request => request instanceof messages.AssignmentListRequest), "the assignments are requested"); + t3.strictSame(subscribed.length, 2, "roles and assignments are subscribed to"); + t3.ok(state.sent.some(request => request instanceof messages.RoleListRequest), "the roles are requested"); + t3.ok( + state.sent.some(request => request instanceof messages.AssignmentListRequest), + "the assignments are requested", + ); const initialise = decodeRcon(state.rcons[0]); - t2.strictSame(initialise.receiver, "initialise", "the lua module is initialised"); - t2.strictSame(initialise.payload.permission_names, ["exp_scenario.gui.readme", "core.admin"], + t3.strictSame(initialise.receiver, "initialise", "the lua module is initialised"); + t3.strictSame(initialise.payload.permission_names, ["exp_scenario.gui.readme", "core.admin"], "each permission name is sent once"); - t2.strictSame(initialise.payload.roles[1].permissions, [0, 1], "roles reference permissions by index"); - t2.strictSame(initialise.payload.assignments, [{ name: "alice", role_ids: [5] }], "the assignments are sent"); + t3.strictSame(initialise.payload.roles[1].permissions, [0, 1], "roles reference permissions by index"); + t3.strictSame(initialise.payload.assignments, [{ name: "alice", role_ids: [5] }], "the assignments are sent"); const emit = decodeRcon(state.rcons[1]); - t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "enabled does not emit changes back"); - t2.strictSame(state.warnings.length, 0, "no warnings with a default role"); + t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "enabled does not emit changes back"); + t3.strictSame(state.warnings.length, 0, "no warnings with a default role"); }); - t2.test("onStart with bidirectional emits changes back", async t2 => { - const { plugin, state } = await startPlugin(t2); + t2.test(".onStart() with bidirectional emits changes back", async t3 => { + const { plugin, state } = await startPlugin(t3); await plugin.onStart(); const emit = decodeRcon(state.rcons[state.rcons.length - 1]); - t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); + t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); }); - t2.test("onStart with disabled does nothing", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + t2.test(".onStart() with disabled does nothing", async t3 => { + const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" }); await plugin.onStart(); - t2.strictSame(state.sent.length, 0, "nothing is sent"); - t2.strictSame(state.rcons.length, 0, "no commands are run"); + t3.strictSame(state.sent.length, 0, "nothing is sent"); + t3.strictSame(state.rcons.length, 0, "no commands are run"); }); - t2.test("onStart warns when no default role is set", async t2 => { + t2.test(".onStart() warns when no default role is set", async t3 => { const roles = sampleRoles().map(record => { record.isDefault = false; return record; }); - const { plugin, state } = await startPlugin(t2, { roles }); + const { plugin, state } = await startPlugin(t3, { roles }); await plugin.onStart(); - t2.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role"); + t3.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role"); }); - t2.test("handleRoleUpdatedEvent and handleAssignmentUpdatedEvent forward to lua", async t2 => { - const { plugin, state } = await startPlugin(t2); + t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() forward to lua", async t3 => { + const { plugin, state } = await startPlugin(t3); await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); const roles = decodeRcon(state.rcons[0]); - t2.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded"); - t2.strictSame(roles.payload[0].name, "Player", "the records are sent in plain form"); + t3.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded"); + t3.strictSame(roles.payload[0].name, "Player", "the records are sent in plain form"); await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); const assignments = decodeRcon(state.rcons[1]); - t2.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); + t3.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); }); - t2.test("handleRoleUpdatedEvent and handleAssignmentUpdatedEvent are gated while disabled", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() are gated while disabled", async t3 => { + const { plugin, state } = await startPlugin(t3, { 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"); + t3.strictSame(state.rcons.length, 0, "no commands are run"); }); - t2.test("handleAssignmentUpdateIPC sends the change and rolls back a refusal", async t2 => { - const { plugin, state } = await startPlugin(t2); + t2.test(".handleAssignmentUpdateIPC() sends the change and rolls back a refusal", async t3 => { + const { plugin, state } = await startPlugin(t3); await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); const request = state.sent[0]; - t2.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request"); - t2.strictSame([request.name, request.assign, request.unassign], ["alice", [5], []], "the request names the change"); + t3.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request"); + t3.strictSame([request.name, request.assign, request.unassign], ["alice", [5], []], "the request names the change"); + + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: undefined, unassign: [5] }); + t3.strictSame(state.sent[1].unassign, [5], "removals are sent the same way"); state.failNext = new Error("User 'alice' does not exist"); await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); const rollback = decodeRcon(state.rcons[0]); - t2.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua"); - t2.strictSame(rollback.payload, { name: "alice", role_ids: [5] }, "the rollback names the refused roles"); + t3.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua"); + t3.strictSame(rollback.payload, { name: "alice", role_ids: [5] }, "the rollback names the refused roles"); + + state.failNext = new Error("User 'alice' does not exist"); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: undefined, unassign: [5] }); + t3.strictSame(state.rcons.length, 1, "a refused removal has nothing to roll back"); }); - t2.test("handleAssignmentUpdateIPC is gated unless bidirectional", async t2 => { - const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); + t2.test(".handleAssignmentUpdateIPC() is gated unless bidirectional", async t3 => { + const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" }); await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); - t2.strictSame(state.sent.length, 0, "nothing is sent"); + t3.strictSame(state.sent.length, 0, "nothing is sent"); }); - t2.test("onInstanceConfigFieldChanged toggles emit with the sync mode", async t2 => { - const { plugin, state } = await startPlugin(t2); + t2.test(".onInstanceConfigFieldChanged() toggles emit with the sync mode", async t3 => { + const { plugin, state } = await startPlugin(t3); 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"); + t3.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting"); }); + t2.end(); }); diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/lua/assignment.lua index 5396e1fe..406fa6fd 100644 --- a/exp_roles/test/lua/assignment.lua +++ b/exp_roles/test/lua/assignment.lua @@ -1,36 +1,37 @@ --- 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, deep_eq = Suite.test, Suite.check, Suite.eq, Suite.deep_eq +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) local players = { - alice = env.add_player("alice", 1), - bob = env.add_player("bob", 2), + alice = env.add_player("alice"), + bob = env.add_player("bob"), } env.initialise{ - env.assignment("alice", { 5 }), - env.assignment("bob", { 6 }), + env.assignment("alice", { "Moderator" }), + env.assignment("bob", { "Regular" }), } env.Roles.set_emit_events(true) env.reset_log() return players end -test("role:assign applies locally and is sent to the controller", function(env) +test(".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" }) - deep_eq(env.sent, { - { channel = "exp_roles:assignment_update", data = { name = "bob", assign = { 5 } } }, + local moderator = env.R("Moderator") + moderator:assign(players.bob, { by_player_name = "alice" }) + eq(env.sent, { + { channel = "exp_roles:assignment_update", data = { name = "bob", assign = { moderator.id } } }, }, "the assignment is sent to the controller") - check(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation") - deep_eq(env.events, { + check(moderator:has_player(players.bob), "the role applies before confirmation") + eq(env.events, { { name = env.Roles.events.on_player_roles_changed, tick = 1, - player_index = 2, - by_player_index = 1, - assigned = { 5 }, + player_index = players.bob.index, + by_player_index = players.alice.index, + assigned = { moderator.id }, unassigned = {}, }, }, "the event carries the change") @@ -38,19 +39,26 @@ test("role:assign applies locally and is sent to the controller", function(env) eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays") local sd = env.Roles._script_data() - eq(sd.local_players.bob, { 5 }, "the role is held locally") - eq(sd.pending.bob, { 5 }, "the role is pending") + eq(sd.local_players.bob, { moderator.id }, "the role is held locally") + eq(sd.pending.bob, { moderator.id }, "the role is pending") env.reset_log() - env.R("Moderator"):assign(players.bob) + moderator:assign(players.bob) check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op") end) -test("receive_assignment_updates releases the local hold once confirmed", function(env) +test(".assign() defaults the by player to game.player", function(env) + local players = setup(env) + game.player = players.alice + env.R("Jail"):assign(players.bob, { silent = true }) + check(env.events[1].by_player_index == players.alice.index, "the acting player is game.player") +end) + +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", { "Regular", "Moderator" }) } check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing") local sd = env.Roles._script_data() @@ -58,44 +66,46 @@ test("receive_assignment_updates releases the local hold once confirmed", functi check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation") end) -test("role:unassign removes a synced role", function(env) +test(".unassign() removes a synced role", function(env) local players = setup(env) - env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true }) - deep_eq(env.sent, { - { channel = "exp_roles:assignment_update", data = { name = "bob", unassign = { 6 } } }, + local regular = env.R("Regular") + regular:unassign(players.bob, { by_player_name = "alice", silent = true }) + eq(env.sent, { + { channel = "exp_roles:assignment_update", data = { name = "bob", unassign = { regular.id } } }, }, "the unassignment is sent to the controller") - check(not env.R("Regular"):has_player(players.bob), "the role is removed locally") - deep_eq(env.events, { + check(not regular:has_player(players.bob), "the role is removed locally") + eq(env.events, { { name = env.Roles.events.on_player_roles_changed, tick = 1, - player_index = 2, - by_player_index = 1, + player_index = players.bob.index, + by_player_index = players.alice.index, assigned = {}, - unassigned = { 6 }, + unassigned = { regular.id }, }, }, "the event carries the change") check(#env.printed == 0, "silent suppresses the announcement") eq(env.sounds, { "bob:utility/game_lost" }, "the unassign sound plays") end) -test("reject_assignment 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") + local jail = env.R("Jail") + jail:assign(players.alice) + check(jail:has_player(players.alice), "the role applies before rejection") env.reset_log() - env.Roles.reject_assignment{ name = "alice", role_ids = { 7 } } - check(not env.R("Jail"):has_player(players.alice), "the rejected role is rolled back") + env.Roles.reject_assignment{ name = "alice", role_ids = { jail.id } } + check(not jail:has_player(players.alice), "the rejected role is rolled back") check(#env.sent == 0, "the rollback is not sent to the controller") - deep_eq(env.events, { + eq(env.events, { { name = env.Roles.events.on_player_roles_changed, tick = 1, - player_index = 1, + player_index = players.alice.index, by_player_index = 0, assigned = {}, - unassigned = { 7 }, + unassigned = { jail.id }, }, }, "the rollback raises the event") @@ -103,44 +113,46 @@ test("reject_assignment rolls the assignment back", function(env) check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") end) -test("role:assign with local_only never reaches the controller", function(env) +test(".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 }) + local regular = env.R("Regular") + regular:assign(players.alice, { silent = true, local_only = true }) check(#env.sent == 0, "the assignment is not sent") - check(env.R("Regular"):has_player(players.alice), "the role applies") + check(regular:has_player(players.alice), "the role applies") local sd = env.Roles._script_data() check(sd.pending.alice == nil, "the role is not pending") - eq(sd.local_players.alice, { 6 }, "the role is held locally") + eq(sd.local_players.alice, { regular.id }, "the role is held locally") - env.Roles.receive_assignment_updates{ env.assignment("alice", { 5 }) } - check(env.R("Regular"):has_player(players.alice), "the role survives controller updates") + env.Roles.receive_assignment_updates{ env.assignment("alice", { "Moderator" }) } + check(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") + regular:unassign(players.alice, { local_only = true }) + check(not regular:has_player(players.alice) and #env.sent == 0, "removed without sync") end) -test("role:assign of a higher priority role suppresses the rest", function(env) +test(".assign() of a higher priority role suppresses the rest", function(env) local players = setup(env) - env.R("Jail"):assign(players.alice, { by_player_name = "", silent = true }) + local jail = env.R("Jail") + jail:assign(players.alice, { by_player_name = "", silent = true }) eq(Suite.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") - eq(env.events[1].assigned, { 7 }, "the event lists the jail role") + eq(env.events[1].assigned, { jail.id }, "the event lists the jail role") check(#env.events[1].unassigned == 0, "the suppressed roles are not listed as unassigned") env.reset_log() - env.R("Jail"):unassign(players.alice, { by_player_name = "", silent = true }) + jail:unassign(players.alice, { by_player_name = "", silent = true }) eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, "unjail restores the roles") check(#env.events[1].assigned == 0, "the restored roles are not listed as assigned") - eq(env.events[1].unassigned, { 7 }, "the unjail event lists the jail role") + eq(env.events[1].unassigned, { jail.id }, "the unjail event lists the jail role") end) -test("role:assign ignores the server", function(env) +test(".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") diff --git a/exp_roles/test/lua/env.lua b/exp_roles/test/lua/env.lua index dc8e246d..b1c7f908 100644 --- a/exp_roles/test/lua/env.lua +++ b/exp_roles/test/lua/env.lua @@ -1,8 +1,7 @@ --[[-- Test environment for module/control.lua 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. +extends each environment with a fresh copy of the roles module and the +fixtures below, and hands each test file a suite built around that. Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite returned here becomes `...` in each test file. @@ -12,7 +11,6 @@ local shared_root = ... --- @type string local source = assert(debug.getinfo(1, "S")).source 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"))() --- Standard fixture: permission names sent once and referenced by zero based index @@ -43,21 +41,33 @@ local function role_records() } end ---- Create an independent environment with a fresh copy of the roles module -local function make_env() - local env = Stubs.new() +--- Fixture role ids by name, avoiding a search of the roles on every lookup +local role_ids = {} +for _, record in ipairs(role_records()) do + role_ids[record.name] = record.id +end +return Framework.suite(function(env) env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))() env.Roles.on_server_startup() - --- Get a role by name, failing the test when it does not exist + --- Get a fixture role by name, failing the test when it does not exist + --- Roles added by a test are found by a search instead function env.R(name) + local role_id = role_ids[name] + if role_id then + return (assert(env.Roles.get_role(role_id), "No role with id " .. role_id)) + end return (assert(env.Roles.get_role_by_name(name), "No role named " .. name)) end - --- An assignment record as the controller would send it - function env.assignment(name, role_ids) - return { name = name, role_ids = role_ids } + --- An assignment record as the controller would send it, roles by name + function env.assignment(player_name, role_names) + local ids = {} + for index, role_name in ipairs(role_names) do + ids[index] = assert(role_ids[role_name], "No fixture role named " .. role_name) + end + return { name = player_name, role_ids = ids } end --- Load the standard fixture and the given assignments @@ -70,6 +80,4 @@ local function make_env() end return env -end - -return Framework.new_suite(make_env) +end) diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/lua/holders.lua index cf458666..486767c8 100644 --- a/exp_roles/test/lua/holders.lua +++ b/exp_roles/test/lua/holders.lua @@ -5,52 +5,53 @@ 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) local players = { - alice = env.add_player("alice", 1), - bob = env.add_player("bob", 2), - dave = env.add_player("dave", 4, false), + alice = env.add_player("alice"), + bob = env.add_player("bob"), + dave = env.add_player("dave", false), } env.initialise{ - env.assignment("alice", { 5 }), - env.assignment("bob", { 6 }), - env.assignment("dave", { 5 }), - env.assignment("zed", { 5 }), + env.assignment("alice", { "Moderator" }), + env.assignment("bob", { "Regular" }), + env.assignment("dave", { "Moderator" }), + env.assignment("zed", { "Moderator" }), } env.Roles.set_emit_events(true) return players end -test("role:get_player_names includes players not on this map", function(env) +test(".get_player_names() includes players not on this map", function(env) setup(env) eq(Suite.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" }, "every player given the role is listed") end) -test("role:get_player_names counts a player in both lists once", function(env) +test(".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", { "Moderator", "Regular" }) } 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") eq(Suite.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }, "the player is counted once") end) -test("role:get_players is limited to this map and can be filtered", function(env) +test(".get_players() is limited to this map and can be filtered", function(env) setup(env) - local mod = env.R("Moderator") - eq(Suite.sorted(Suite.names(mod:get_players())), { "alice", "dave" }, "players are limited to this map") - eq(Suite.names(mod:get_players(true)), { "alice" }, "filtered to connected players") - eq(Suite.names(mod:get_players(false)), { "dave" }, "filtered to offline players") + local moderator = env.R("Moderator") + eq(Suite.sorted(Suite.names(moderator:get_players())), { "alice", "dave" }, "players are limited to this map") + eq(Suite.names(moderator:get_players(true)), { "alice" }, "filtered to connected players") + eq(Suite.names(moderator:get_players(false)), { "dave" }, "filtered to offline players") end) -test("role:print reaches online holders", function(env) +test(".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") + check(env.R("Jail"):print("hello") == 0, "a role with no online holders reaches nobody") end) -test("role:print over get_higher_roles can repeat", function(env) +test(".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() diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/lua/lookup.lua index e946fc80..56db829d 100644 --- a/exp_roles/test/lua/lookup.lua +++ b/exp_roles/test/lua/lookup.lua @@ -2,42 +2,48 @@ 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("get_role returns the role with the given clusterio id", function(env) +test("get_role() returns the role with the given clusterio id", function(env) env.initialise{} - 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(env.R("Regular").id) == env.R("Regular"), "the role is found by id") check(env.Roles.get_role(9) == nil, "deleted roles are not known") end) -test("get_role_by_name searches the roles", 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("Moderator") == env.R("Moderator"), "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) +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) +test("get_ordered_roles() returns the most privileged first", function(env) env.initialise{} eq(Suite.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) +test("sort_roles() orders most privileged first and breaks ties on the id", function(env) env.initialise{} local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") } eq(Suite.names(sorted), { "Cluster Admin", "Regular", "Player" }, "the list is sorted in place") + + -- A role with the same order as Regular but a higher id sorts after it + env.Roles.receive_role_updates{ + { id = 10, name = "Twin", permissions = {}, meta = { id = 0, order = env.R("Regular").order } }, + } + local tied = env.Roles.sort_roles{ env.R("Twin"), env.R("Regular") } + eq(Suite.names(tied), { "Regular", "Twin" }, "order ties break on the id") end) -test("get_default_role follows is_default", function(env) +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) +test("get_higher_roles() and get_lower_roles() include the role and exclude the default", function(env) env.initialise{} eq(Suite.sorted(Suite.names(env.Roles.get_higher_roles(env.R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }, "higher roles") @@ -45,14 +51,14 @@ test("get_higher_roles and get_lower_roles include the role and exclude the defa { "Jail", "Regular" }, "lower roles") end) -test("role:is_higher_than and role:is_lower_than compare on order", function(env) +test(".is_higher_than() and .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) +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") diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/lua/players.lua index 7ec01258..57f85ded 100644 --- a/exp_roles/test/lua/players.lua +++ b/exp_roles/test/lua/players.lua @@ -5,18 +5,18 @@ 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) local players = { - alice = env.add_player("alice", 1), - bob = env.add_player("bob", 2), - carol = env.add_player("carol", 3), + alice = env.add_player("alice"), + bob = env.add_player("bob"), + carol = env.add_player("carol"), } env.initialise{ - env.assignment("alice", { 5 }), - env.assignment("bob", { 6 }), + env.assignment("alice", { "Moderator" }), + env.assignment("bob", { "Regular" }), } return players end -test("get_player_roles includes the default role", function(env) +test("get_player_roles() includes the default role", function(env) local players = setup(env) eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, "held roles and the default role") @@ -24,7 +24,7 @@ test("get_player_roles includes the default role", function(env) "a player with no roles has the default role") end) -test("get_player_roles treats nil and index 0 as the server", function(env) +test("get_player_roles() treats nil and index 0 as the server", function(env) setup(env) eq(Suite.names(env.Roles.get_player_roles(nil)), { "" }, "nil is the server") eq(Suite.names(env.Roles.get_player_roles(env.server)), { "" }, "index 0 is the server") @@ -32,20 +32,28 @@ test("get_player_roles treats nil and index 0 as the server", function(env) check(not env.R("Player"):has_player(nil), "the server does not have the default role") end) -test("get_player_highest_role", function(env) +test("get_player_highest_role() returns the most privileged role", function(env) local players = setup(env) 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("player_has_permission", function(env) +test("get_player_highest_role() errors when a player has no roles", function(env) + local players = setup(env) + env.Roles.receive_role_updates{ + { id = env.R("Player").id, name = "Player", permissions = {}, meta = { id = 0, order = 5 }, is_deleted = true }, + } + check(not pcall(env.Roles.get_player_highest_role, players.carol), "no default role and no held roles errors") +end) + +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("player_has_any_permission and player_has_all_permission", 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") @@ -56,23 +64,24 @@ test("player_has_any_permission and player_has_all_permission", function(env) check(not env.Roles.player_has_all_permission(players.bob, jail, kill), "all fails when one is missing") check(env.Roles.player_has_all_permission(players.bob), "all of none is true") check(env.Roles.player_has_any_permission(nil, "anything.at.all"), "the server passes any") + check(env.Roles.player_has_all_permission(nil, "anything.at.all", "and.this.too"), "the server passes all") end) -test("role:has_permission", function(env) +test(".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") end) -test("role:has_player", function(env) +test(".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("player_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") diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/lua/sync.lua index f02fb3f4..4bdc043a 100644 --- a/exp_roles/test/lua/sync.lua +++ b/exp_roles/test/lua/sync.lua @@ -1,61 +1,69 @@ --- 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 +local test, check, eq = Suite.test, Suite.check, Suite.eq --- alice is a moderator and carol has only the default role, both connected local function setup(env) local players = { - alice = env.add_player("alice", 1), - carol = env.add_player("carol", 3), + alice = env.add_player("alice"), + carol = env.add_player("carol"), } env.admin_state = {} env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) 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", { "Moderator" }), + env.assignment("zed", { "Moderator", "Regular" }), -- has never joined this map } env.Roles.set_emit_events(true) return players end -test("initialise applies triggers and raises empty events", function(env) +test("initialise() applies triggers and raises empty events", function(env) setup(env) - deep_eq(env.admin_state, { alice = true, carol = false }, "triggers run for connected players") + 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].assigned == 0 and #env.events[1].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("initialise is authoritative for pending roles", function(env) +test("initialise() skips deleted assignments", function(env) + local players = setup(env) + local record = env.assignment("carol", { "Moderator" }) + record.is_deleted = true + env.initialise{ record } + check(not env.R("Moderator"):has_player(players.carol), "a deleted assignment is ignored") +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() - eq(sd.pending.carol, { 5 }, "the role is pending before initialise") + eq(sd.pending.carol, { env.R("Moderator").id }, "the role is pending before initialise") env.reset_log() - env.initialise{ env.assignment("alice", { 5 }) } + env.initialise{ env.assignment("alice", { "Moderator" }) } 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") + eq(sd.local_players.carol, { env.R("Jail").id }, "local only roles are kept") check(#env.sent == 0, "initialise sends nothing") end) -test("receive_assignment_updates applies controller changes", function(env) +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", { "Moderator" }) } check(env.R("Moderator"):has_player(players.carol), "the assignment applies") - deep_eq(env.events, { + eq(env.events, { { name = env.Roles.events.on_player_roles_changed, tick = 1, - player_index = 3, + player_index = players.carol.index, by_player_index = 0, - assigned = { 5 }, + assigned = { env.R("Moderator").id }, unassigned = {}, }, }, "the event carries the change with no by player") @@ -65,53 +73,58 @@ test("receive_assignment_updates applies controller changes", 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") - eq(env.events[1].unassigned, { 5 }, "the removal raises the event") + eq(env.events[1].unassigned, { env.R("Moderator").id }, "the removal raises the event") check(env.admin_state.carol == false, "triggers follow the removal") end) -test("receive_assignment_updates for players not on this map raises 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", { "Moderator" }) } check(#env.events == 0 and #env.printed == 0, "no event and no announcement") end) -test("receive_role_updates applies to the holders", function(env) +test("receive_role_updates() applies to the holders", function(env) setup(env) env.reset_log() + local regular_id = env.R("Regular").id env.Roles.receive_role_updates{ - { id = 6, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } }, + { id = regular_id, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } }, } check(env.R("Regular"):has_permission("exp_scenario.command.jail"), "the permission change applies") check(#env.events == 2, "a role change raises for every connected player") check(#env.events[1].assigned == 0, "role change events are empty") env.Roles.receive_role_updates{ - { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, + { id = regular_id, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, } - check(env.Roles.get_role(6) == nil, "a deleted role is removed") + check(env.Roles.get_role(regular_id) == nil, "a deleted role is removed") end) -test("receive_role_updates moves the default role", 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 = env.R("Player").id, 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") eq(Suite.names(env.Roles.get_player_roles(players.carol)), { "Guest" }, "players pick up the new default") end) -test("set_emit_events gates what is sent", function(env) +test("set_emit_events() gates what is sent and defaults to enabled", function(env) local players = setup(env) env.Roles.set_emit_events(false) env.reset_log() env.R("Regular"):assign(players.alice) - check(#env.sent == 0, "the assignment is not sent") + check(#env.sent == 0, "nothing is sent while disabled") check(env.R("Regular"):has_player(players.alice), "the assignment still applies locally") + + env.Roles.set_emit_events() + env.R("Jail"):assign(players.alice, { silent = true }) + check(#env.sent == 1, "no argument enables sending") end) -test("on_load restores the state after a save and load", function(env) +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() @@ -123,10 +136,10 @@ test("on_load restores the state after a save and load", function(env) 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) +test("on_player_joined_game() runs the triggers", function(env) + local players = setup(env) env.admin_state.alice = nil - env.Roles.on_player_joined_game{ player_index = 1 } + env.Roles.on_player_joined_game{ player_index = players.alice.index } check(env.admin_state.alice ~= nil, "triggers run when a player joins") end) diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js index b2105072..ba89b550 100644 --- a/exp_roles/test/messages.test.js +++ b/exp_roles/test/messages.test.js @@ -96,7 +96,7 @@ t.test("class AssignmentUpdateRequest", subtest => { subtest.end(); }); -t.test("encodeRolesForLua sends each permission name once", subtest => { +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)), diff --git a/exp_roles/test/seed.test.js b/exp_roles/test/seed.test.js index 755ddc03..010dbd5d 100644 --- a/exp_roles/test/seed.test.js +++ b/exp_roles/test/seed.test.js @@ -15,7 +15,7 @@ t.test("seedRoles grant only defined permissions", subtest => { subtest.end(); }); -t.test("flattenSeedPermissions carries parents into their children", 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) { diff --git a/test/lua/framework.lua b/test/lua/framework.lua index f8fbe1ef..dfda4fc9 100644 --- a/test/lua/framework.lua +++ b/test/lua/framework.lua @@ -1,11 +1,15 @@ --[[-- Test framework for plugin module tests 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. +or method it covers. Every test function receives a fresh environment: the +stubs extended by the function the suite was created with, so tests are +independent and can not leak state into each other. ]] +local source = assert(debug.getinfo(1, "S")).source +local shared_root = assert(source:match("^@(.*)/framework%.lua$")) +local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))() + local Framework = {} --- Turn a value into a string for failure messages @@ -21,8 +25,8 @@ local function repr(value, depth) 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) +--- @param extend_env fun(env: table): table Extends the stubs given to each test +function Framework.suite(extend_env) --- @class Suite local Suite = { tests = {}, -- { name, fn } in declaration order @@ -70,20 +74,6 @@ function Framework.new_suite(make_env) end 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 local function deep_equal(a, b) if type(a) ~= "table" or type(b) ~= "table" then return a == b end @@ -97,7 +87,7 @@ function Framework.new_suite(make_env) end --- Check two values are equal, comparing tables recursively - function Suite.deep_eq(a, b, name) + function Suite.eq(a, b, name) Suite.check(deep_equal(a, b), name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1))) end @@ -126,7 +116,9 @@ function Framework.new_suite(make_env) function Suite.run() for _, test in ipairs(Suite.tests) do current_test = test.name - local ok, err = pcall(test.fn, make_env()) + local ok, err = pcall(function() + test.fn(extend_env(Stubs.new())) + end) if not ok then Suite.fail("did not error", tostring(err)) end diff --git a/test/lua/stubs.lua b/test/lua/stubs.lua index 4a4a91bd..e587728e 100644 --- a/test/lua/stubs.lua +++ b/test/lua/stubs.lua @@ -95,8 +95,9 @@ function Stubs.new() get_player = function(key) return players[key] end, }, { "player" }) - --- Add a player to the stubbed game - function stubs.add_player(name, index, is_connected) + --- Add a player to the stubbed game, indexes are assigned in join order + function stubs.add_player(name, is_connected) + local index = #players_by_index + 1 local player = Stubs.strict("LuaPlayer " .. name, { name = name, index = index,