From 24d36db601ae5dc52eb60ba6137e744b8d49bc0a Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:45:51 +0000 Subject: [PATCH] Address review on the test harness - The generic parts move to test/ in the repository root so other plugins can reuse them: the factorio and clusterio stubs, the test framework, the fengari runner, and clusterio's testMatrix and round trip helpers. A plugin composes them from its own env.lua, which adds its stubs and fixtures. - Tests are declared with Test.test(name, fn) and every test function receives a fresh environment, so nothing carries over between them. A test which errors is reported as a failure rather than aborting the file. - Every stub raises on properties it does not implement, which mirrors the game api. game.player is the one property allowed to read as nil. - Test.deep_eq compares tables recursively with keys checked from both sides. - The message records round trip through the same testMatrix and testRoundTripJsonSerialisable helpers the clusterio tests use, covering every optional field combination of the records, events and requests. - controller.test.js and instance.test.js cover the node side of the plugin against faked controller and instance internals: property creation and sweeping, record building, broadcasts, subscription replay, assignment validation, auto assignment and its blocking role, seeding, the initialise payload, sync mode gating, and the rejection rollback. Co-Authored-By: Claude Fable 5 --- exp_roles/test/controller.test.js | 195 ++++++++++++++++++ exp_roles/test/instance.test.js | 143 +++++++++++++ exp_roles/test/lua/assignment.lua | 188 +++++++++-------- exp_roles/test/lua/env.lua | 151 ++------------ exp_roles/test/lua/holders.lua | 113 ++++++---- exp_roles/test/lua/lookup.lua | 66 +++--- exp_roles/test/lua/players.lua | 117 ++++++----- exp_roles/test/lua/sync.lua | 175 +++++++++------- exp_roles/test/messages.test.js | 76 +++++-- exp_roles/test/module.test.js | 9 +- test/common.js | 41 ++++ test/lua/framework.lua | 109 ++++++++++ .../test/helpers/lua.js => test/lua/runner.js | 39 ++-- test/lua/stubs.lua | 118 +++++++++++ 14 files changed, 1106 insertions(+), 434 deletions(-) create mode 100644 exp_roles/test/controller.test.js create mode 100644 exp_roles/test/instance.test.js create mode 100644 test/common.js create mode 100644 test/lua/framework.lua rename exp_roles/test/helpers/lua.js => test/lua/runner.js (53%) create mode 100644 test/lua/stubs.lua diff --git a/exp_roles/test/controller.test.js b/exp_roles/test/controller.test.js new file mode 100644 index 00000000..2536ef44 --- /dev/null +++ b/exp_roles/test/controller.test.js @@ -0,0 +1,195 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +const { ControllerPlugin } = require("../dist/node/controller"); +const messages = require("../dist/node/messages"); +const { seedRoles } = require("../dist/node/seed"); + +// Importing this defines the exp_scenario permissions the seed grants +require("@expcluster/scenario/dist/node/permissions"); + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +class FakeUser { + constructor(name, roleIds = []) { + this.name = name; + this.roleIds = new Set(roleIds); + this.playerStats = { onlineTimeMs: 0 }; + this.updatedAtMs = 0; + this.isDeleted = false; + } + + set(field, value) { + this[field] = value; + this.updatedAtMs += 1; + } +} + +/** Build a plugin around fake controller internals, started on first use by each test. */ +async function startPlugin(t2, { roles = [], users = [], config = {} } = {}) { + const state = { broadcasts: [], permissionUpdates: [] }; + const configValues = { + "controller.database_directory": t2.testdir(), + "controller.default_role_id": lib.Role.DefaultPlayerRoleId, + ...config, + }; + + const roleStore = new lib.SubscribableDatastore( + ...await new lib.MemoryDatastoreProvider(new Map(roles.map(role => [role.id, role]))).bootstrap() + ); + const userStore = new Map(users.map(user => [user.name, user])); + + const controller = { + config: { get: key => configValues[key] }, + roles: roleStore, + users: { + records: { on: () => {}, values: () => userStore.values() }, + getByNameMutable: name => userStore.get(name), + valuesMutable: () => userStore.values(), + getOrCreateUser: name => { + if (!userStore.has(name)) { + userStore.set(name, new FakeUser(name)); + } + return userStore.get(name); + }, + }, + subscriptions: { handle: () => {}, broadcast: event => state.broadcasts.push(event) }, + handle: () => {}, + userPermissionsUpdated: user => state.permissionUpdates.push(user.name), + }; + + const plugin = new ControllerPlugin({ name: "exp_roles" }, controller, undefined, logger); + await plugin.init(); + return { plugin, controller, state, users: userStore }; +} + +const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions)); + +t.test("init creates properties for existing roles and sweeps orphans", async t2 => { + const { plugin } = await startPlugin(t2, { roles: [role(0, "Cluster Admin"), role(5, "Moderator")] }); + + t2.strictSame(plugin.roleMeta.get(0).order, 1, "the first role is ordered first"); + t2.strictSame(plugin.roleMeta.get(5).order, 2, "the next role is ordered after it"); + + plugin.roleMeta.set(new messages.RoleMetaRecord(99, 3)); + plugin.sweepRoleMeta(); + t2.notOk(plugin.roleMeta.get(99), "properties without a role are removed"); + t2.ok(plugin.roleMeta.get(5), "properties with a role are kept"); +}); + +t.test("role records combine the role with its properties", async t2 => { + const { plugin } = await startPlugin(t2, { + roles: [role(1, "Player"), role(5, "Moderator", ["exp_scenario.command.kill"])], + }); + plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 1, "Mod")); + + const record = plugin.buildRoleRecord(plugin.controller.roles.get(5)); + t2.strictSame(record.name, "Moderator", "the name comes from the role"); + t2.strictSame(record.permissions, ["exp_scenario.command.kill"], "the permissions come from the role"); + t2.strictSame(record.meta.shortHand, "Mod", "the properties come from the datastore"); + t2.notOk(record.isDefault, "not the default role"); + t2.ok(plugin.buildRoleRecord(plugin.controller.roles.get(1)).isDefault, "the default role is marked"); +}); + +t.test("role changes broadcast to subscribers", async t2 => { + const { plugin, controller, state } = await startPlugin(t2, { roles: [role(1, "Player")] }); + + state.broadcasts.length = 0; + controller.roles.set(role(5, "Moderator")); + const updated = state.broadcasts.flatMap(event => event.updates.map(update => update.name)); + t2.ok(updated.includes("Moderator"), "a new role is broadcast"); + + state.broadcasts.length = 0; + plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 0, "Mod")); + t2.ok(state.broadcasts.some( + event => event instanceof messages.RoleUpdatedEvent && event.updates[0].meta.shortHand === "Mod" + ), "a property change is broadcast as its role"); + + state.broadcasts.length = 0; + await plugin.onControllerConfigFieldChanged("controller.default_role_id"); + t2.ok(state.broadcasts.some(event => event instanceof messages.RoleUpdatedEvent), "a default role change rebroadcasts"); +}); + +t.test("subscriptions replay only newer records", async t2 => { + const { plugin, controller } = await startPlugin(t2, { roles: [role(1, "Player")] }); + controller.roles.set(role(5, "Moderator")); + const updatedAtMs = plugin.buildRoleRecord(controller.roles.get(5)).updatedAtMs; + + const all = await plugin.handleRoleSubscription({ lastRequestTimeMs: 0 }); + t2.strictSame(all.updates.length, 2, "everything is replayed from the start"); + const none = await plugin.handleRoleSubscription({ lastRequestTimeMs: updatedAtMs }); + t2.strictSame(none, null, "nothing is replayed when up to date"); +}); + +t.test("assignments mirror users without the default role", async t2 => { + const alice = new FakeUser("alice", [lib.Role.DefaultPlayerRoleId, 5]); + alice.updatedAtMs = 10; + const bob = new FakeUser("bob", [lib.Role.DefaultPlayerRoleId]); + const { plugin } = await startPlugin(t2, { roles: [role(1, "Player"), role(5, "Moderator")], users: [alice, bob] }); + + const records = plugin.listAssignmentRecords(); + t2.strictSame(records.length, 1, "users with only the default role are left out"); + t2.strictSame(records[0].name, "alice"); + t2.strictSame([...records[0].roleIds], [5], "the default role is not listed"); +}); + +t.test("assignment updates validate the user and roles", async t2 => { + const alice = new FakeUser("alice", [5]); + const { plugin, state } = await startPlugin(t2, { + roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")], users: [alice], + }); + + await t2.rejects( + plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("nobody", [], [])), + { message: /does not exist/ }, "an unknown user is refused", + ); + await t2.rejects( + plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("alice", [99], [])), + { message: /does not exist/ }, "an unknown role is refused", + ); + + await plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("alice", [6], [5])); + t2.strictSame([...alice.roleIds], [6], "roles are added and removed"); + t2.ok(state.permissionUpdates.includes("alice"), "permission changes are pushed to the user"); +}); + +t.test("roles are granted from online time", async t2 => { + const newcomer = new FakeUser("newcomer"); + newcomer.playerStats.onlineTimeMs = 3600000; + const veteran = new FakeUser("veteran"); + veteran.playerStats.onlineTimeMs = 7200000; + const jailed = new FakeUser("jailed", [7]); + jailed.playerStats.onlineTimeMs = 7200000; + + const { plugin } = await startPlugin(t2, { + roles: [role(1, "Player"), role(6, "Regular"), role(7, "Jail")], + users: [newcomer, veteran, jailed], + }); + // The blocking role is marked first, since setting properties applies auto assignment + plugin.roleMeta.set(new messages.RoleMetaRecord(7, 3, 1, "", "", null, null, true)); + plugin.roleMeta.set(new messages.RoleMetaRecord(6, 2, 0, "", "", null, 7200000)); + t2.ok(veteran.roleIds.has(6), "enough online time grants the role"); + t2.notOk(newcomer.roleIds.has(6), "not enough online time does not"); + t2.notOk(jailed.roleIds.has(6), "a blocking role prevents the grant"); + + veteran.roleIds.delete(6); + await plugin.onPlayerEvent(undefined, { type: "leave", name: "veteran" }); + t2.ok(veteran.roleIds.has(6), "granted again when the player leaves"); +}); + +t.test("seeding creates the roles and reuses them by name", async t2 => { + const { plugin, controller } = await startPlugin(t2, { + roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")], + }); + + await plugin.handleSeedRolesRequest(); + t2.strictSame(controller.roles.size, seedRoles.length, "every seed role exists"); + + const moderator = [...controller.roles.values()].find(other => other.name === "Moderator"); + t2.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in"); + t2.ok(plugin.roleMeta.get(moderator.id), "the role properties are created"); + t2.strictSame(plugin.roleMeta.get(moderator.id).shortHand, "Mod", "the properties match the seed"); + + await plugin.handleSeedRolesRequest(); + t2.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles"); +}); diff --git a/exp_roles/test/instance.test.js b/exp_roles/test/instance.test.js new file mode 100644 index 00000000..161b4f28 --- /dev/null +++ b/exp_roles/test/instance.test.js @@ -0,0 +1,143 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +const { InstancePlugin } = require("../dist/node/instance"); +const messages = require("../dist/node/messages"); + +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); + +const sampleRoles = () => [ + new messages.RoleRecord(1, "Player", ["exp_scenario.gui.readme"], new messages.RoleMetaRecord(1, 2), true), + new messages.RoleRecord(5, "Moderator", ["exp_scenario.gui.readme", "core.admin"], new messages.RoleMetaRecord(5, 1)), +]; +const sampleAssignments = () => [new messages.AssignmentRecord("alice", new Set([5]))]; + +/** Build a plugin around a fake instance, started on first use by each test. */ +async function startPlugin(t2, { syncMode = "bidirectional", roles = sampleRoles() } = {}) { + const state = { sent: [], rcons: [], warnings: [], failNext: null }; + const instance = { + logger, + config: { get: field => (field === "exp_roles.sync_mode" ? syncMode : undefined) }, + 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), + }; + + const plugin = new InstancePlugin({ name: "exp_roles" }, instance, {}); + plugin.logger = { ...logger, warn: message => state.warnings.push(message) }; + await plugin.init(); + return { plugin, state }; +} + +/** The lua receiver and payload of a recorded rcon command. */ +function decodeRcon(command) { + const match = command.match(/^\/sc exp_roles\.(\w+)\(helpers\.json_to_table\[=\[(.*)\]=\]\)$/s); + return { receiver: match[1], payload: JSON.parse(match[2]) }; +} + +t.test("start subscribes and initialises the lua module", async t2 => { + const { plugin, state } = await startPlugin(t2, { 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"); + + 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"], + "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"); + + 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"); +}); + +t.test("bidirectional emits changes back", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.onStart(); + const emit = decodeRcon(state.rcons[state.rcons.length - 1]); + t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", true]); +}); + +t.test("disabled does nothing on start", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + await plugin.onStart(); + t2.strictSame(state.sent.length, 0, "nothing is sent"); + t2.strictSame(state.rcons.length, 0, "no commands are run"); +}); + +t.test("a missing default role is warned about", async t2 => { + const roles = sampleRoles().map(record => { record.isDefault = false; return record; }); + const { plugin, state } = await startPlugin(t2, { roles }); + await plugin.onStart(); + t2.ok(state.warnings.some(message => /default role/.test(message)), "the warning names the default role"); +}); + +t.test("updates from the controller are forwarded to lua", async t2 => { + const { plugin, state } = await startPlugin(t2); + 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"); + + await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); + const assignments = decodeRcon(state.rcons[1]); + t2.strictSame(assignments.receiver, "receive_assignment_updates", "assignment updates are forwarded"); +}); + +t.test("updates are not forwarded while disabled", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); + await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); + await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); + t2.strictSame(state.rcons.length, 0, "no commands are run"); +}); + +t.test("in game changes are sent up and rolled back when refused", async t2 => { + const { plugin, state } = await startPlugin(t2); + 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"); + + 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"); +}); + +t.test("in game changes are dropped unless bidirectional", async t2 => { + const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); + await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); + t2.strictSame(state.sent.length, 0, "nothing is sent"); +}); + +t.test("changing the sync mode toggles emit", async t2 => { + const { plugin, state } = await startPlugin(t2); + await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional"); + const emit = decodeRcon(state.rcons[0]); + t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "leaving bidirectional stops emitting"); +}); diff --git a/exp_roles/test/lua/assignment.lua b/exp_roles/test/lua/assignment.lua index cba6b7cc..b77ba40b 100644 --- a/exp_roles/test/lua/assignment.lua +++ b/exp_roles/test/lua/assignment.lua @@ -1,93 +1,119 @@ --- 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 Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -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() +--- 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), + } + env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("bob", { 6 }), + } + env.Roles.set_emit_events(true) + env.reset_log() + return players +end --- 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") +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" }) + check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "the assignment is sent") + check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "the payload names the role and player") + check(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation") + check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event carries the assigned role id") + check(env.events[1].data.by_player_index == 1, "the event carries who made the change") + check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced") + check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "the assign sound plays") -env.reset_log() -R("Moderator"):assign(bob) -check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op") + local sd = env.Roles._script_data() + check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "the role is held locally and pending") --- 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") + env.reset_log() + env.R("Moderator"):assign(players.bob) + check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op") +end) --- 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 }) } +test("a confirmation releases the local hold", 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 }) } + check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing") --- 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 sd = env.Roles._script_data() + check(sd.local_players.bob == nil and sd.pending.bob == nil, "the role is no longer held locally") + check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation") +end) --- 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") +test("unassign a synced role", function(env) + local players = setup(env) + env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true }) + check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 6 }), "the unassignment is sent") + check(not env.R("Regular"):has_player(players.bob), "the role is removed locally") + check(#env.events == 1 and eq(env.events[1].data.unassigned, { 6 }), "the event carries the unassigned role id") + check(#env.printed == 0, "silent suppresses the announcement") + check(#env.sounds == 1 and env.sounds[1] == "bob:utility/game_lost", "the unassign sound plays") +end) --- 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") +test("a rejection 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") --- 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") + 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") + 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") -return Env.results_json(env) + local sd = env.Roles._script_data() + check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state") +end) + +test("local only roles never reach the controller", function(env) + local players = setup(env) + env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) + check(#env.sent == 0, "a local only role is not sent") + check(env.R("Regular"):has_player(players.alice), "a local only role applies") + + local sd = env.Roles._script_data() + check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending") + + env.Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) } + check(env.R("Regular"):has_player(players.alice), "a local only role survives controller updates") + + env.reset_log() + env.R("Regular"):unassign(players.alice, { local_only = true }) + check(not env.R("Regular"):has_player(players.alice) and #env.sent == 0, "removed without sync") +end) + +test("jail suppresses every other role", function(env) + local players = setup(env) + env.R("Jail"):assign(players.alice, { by_player_name = "", silent = true }) + check(eq(Test.names(env.Roles.get_player_roles(players.alice)), { "Jail" }), "only the jail role applies") + check(env.R("Moderator"):has_player(players.alice), "a suppressed role is still held") + check(not env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permissions are lost") + check(not env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "the default role is lost") + check(not env.Roles.player_outranks(players.alice, players.bob), "a jailed player no longer outranks") + check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0, + "the event lists only the jail role") + + env.reset_log() + env.R("Jail"):unassign(players.alice, { by_player_name = "", silent = true }) + check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }), + "unjail restores the roles") + check(#env.events[1].data.assigned == 0 and eq(env.events[1].data.unassigned, { 7 }), + "the unjail event lists only the jail role") +end) + +test("the server can not be assigned roles", function(env) + setup(env) + env.R("Moderator"):assign(env.server) + check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op") +end) + +return Env.finish() diff --git a/exp_roles/test/lua/env.lua b/exp_roles/test/lua/env.lua index 33bf7b40..ddec6edc 100644 --- a/exp_roles/test/lua/env.lua +++ b/exp_roles/test/lua/env.lua @@ -1,14 +1,23 @@ --[[-- 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. +Composes the shared stubs and framework from the repository test folder with +the fixtures for this plugin. Each call to Env.new() is an independent +environment with a fresh copy of the roles module. -Run as a chunk by test/helpers/lua.js, receiving the module directory and +Run as a chunk by test/lua/runner.js, receiving the shared folder and returning this table, which is in turn passed to each test chunk. ]] -local module_root = ... --- @type string +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source +local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$")) -local Env = {} +local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))() +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() + +local Env = { + --- Test registry for the file being run, see framework.lua + Test = Framework.new(), +} --- Standard fixture: permission names sent once and referenced by zero based index Env.permission_names = { @@ -44,83 +53,12 @@ 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 env = Stubs.new() - 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 = assert(loadfile(plugin_root .. "/module/control.lua"))() env.Roles.on_server_startup() - --- Get a role by name, failing the test file when it does not exist + --- Get a role by name, failing the test when it does not exist function env.R(name) return (assert(env.Roles.get_role_by_name(name), "No role named " .. name)) end @@ -134,60 +72,13 @@ function Env.new() } 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, ",") .. "]" +--- Run the declared tests, each against a fresh environment +function Env.finish() + Env.Test.run(Env.new) + return Env.Test.results_json() end return Env diff --git a/exp_roles/test/lua/holders.lua b/exp_roles/test/lua/holders.lua index bb345ddf..398377ec 100644 --- a/exp_roles/test/lua/holders.lua +++ b/exp_roles/test/lua/holders.lua @@ -1,47 +1,76 @@ --- 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 Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -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") +--- 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), + } + env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("bob", { 6 }), + Env.assignment("dave", { 5 }), + Env.assignment("zed", { 5 }), + } + env.Roles.set_emit_events(true) + return players end -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) +test("player names include players not on this map", function(env) + setup(env) + check(eq(Test.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" }), + "every player given the role is listed") +end) + +test("players are limited to this map and can be filtered", function(env) + setup(env) + local mod = env.R("Moderator") + check(eq(Test.sorted(Test.names(mod:get_players())), { "alice", "dave" }), "players are limited to this map") + check(eq(Test.names(mod:get_players(true)), { "alice" }), "filtered to connected players") + check(eq(Test.names(mod:get_players(false)), { "dave" }), "filtered to offline players") +end) + +test("a player holding the role in both lists is counted once", function(env) + 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 }) } + + local sd = env.Roles._script_data() + check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists") + check(eq(Test.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }), "the player is counted once") +end) + +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") +end) + +test("printing to the higher roles", function(env) + local players = setup(env) + env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) + env.reset_log() + for _, role in ipairs(env.Roles.get_higher_roles(env.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(Test.sorted(got), { "alice", "alice", "bob" }), "the online holders of each role are reached") +end) + +test("deep equality helper", function(env) + check(Test.deep_eq({ a = { 1, 2 }, b = "x" }, { a = { 1, 2 }, b = "x" }), "equal nested tables") + check(not Test.deep_eq({ a = { 1, 2 } }, { a = { 1, 3 } }), "differing nested values") + check(not Test.deep_eq({ a = 1 }, { a = 1, b = 2 }), "extra keys on the right") + check(not Test.deep_eq({ a = 1, b = 2 }, { a = 1 }), "extra keys on the left") + check(Test.deep_eq(env.Roles._script_data().pending, {}), "works against module state") +end) + +return Env.finish() diff --git a/exp_roles/test/lua/lookup.lua b/exp_roles/test/lua/lookup.lua index b32bf3b0..f188bf46 100644 --- a/exp_roles/test/lua/lookup.lua +++ b/exp_roles/test/lua/lookup.lua @@ -1,36 +1,48 @@ --- 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 +local Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -env.initialise{} +test("roles are ordered most privileged first", function(env) + env.initialise{} + check(eq(Test.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }), + "ordered roles follow their order with deleted roles skipped") + check(#env.Roles.get_roles() == 5, "get_roles returns every role") -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") + local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") } + check(eq(Test.names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first") +end) -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") +test("roles are looked up by id", function(env) + env.initialise{} + check(env.Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id") + check(env.Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles") + check(env.Roles.get_role(env.R("Jail").id) == env.R("Jail"), "lookups return the same object") + check(env.Roles.get_role(9) == nil and env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known") + check(env.Roles.get_default_role() == env.R("Player"), "the default role comes from is_default") +end) -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") +test("role records are decoded", 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") + check(env.R("Moderator").color.g == 170, "color decoded") + check(env.R("Jail").priority == 1 and env.R("Jail").block_auto_assign, "priority and block auto assign decoded") +end) -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") +test("roles compare on their order", function(env) + env.initialise{} + check(env.R("Moderator"):is_higher_than(env.R("Player")), "a lower order is more privileged") + check(env.R("Player"):is_lower_than(env.R("Moderator")), "is_lower_than is the reverse") + check(not env.R("Moderator"):is_higher_than(env.R("Moderator")), "a role is not higher than itself") +end) -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") +test("higher and lower roles", function(env) + env.initialise{} + check(eq(Test.sorted(Test.names(env.Roles.get_higher_roles(env.R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }), + "higher roles include the role itself and exclude the default role") + check(eq(Test.sorted(Test.names(env.Roles.get_lower_roles(env.R("Regular")))), { "Jail", "Regular" }), + "lower roles include the role itself and exclude the default role") +end) -return Env.results_json(env) +return Env.finish() diff --git a/exp_roles/test/lua/players.lua b/exp_roles/test/lua/players.lua index c77bb349..4a49ae6c 100644 --- a/exp_roles/test/lua/players.lua +++ b/exp_roles/test/lua/players.lua @@ -1,56 +1,81 @@ --- 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 Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -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 }), -} +--- 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), + } + env.initialise{ + Env.assignment("alice", { 5 }), + Env.assignment("bob", { 6 }), + } + return players +end -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") +test("player roles include the default role", function(env) + local players = setup(env) + check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }), + "held roles and the default role") + check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Player" }), + "a player with no roles has the default role") +end) -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") +test("the server is nil or a player with index 0", function(env) + setup(env) + check(eq(Test.names(env.Roles.get_player_roles(nil)), { "" }), "nil is the server") + check(eq(Test.names(env.Roles.get_player_roles(env.server)), { "" }), "index 0 is the server") + check(env.Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission") + check(not env.R("Player"):has_player(nil), "the server does not have the default role") +end) -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") +test("the highest role", function(env) + local players = setup(env) + check(env.Roles.get_player_highest_role(players.alice) == env.R("Moderator"), "highest held role") + check(env.Roles.get_player_highest_role(players.carol) == env.R("Player"), "the default role when none are held") +end) -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") +test("permission checks", 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) -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") +test("has any and has all", 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") + check(not env.Roles.player_has_any_permission(players.bob, jail, "exp_scenario.command.assign_role"), + "any fails when none are granted") + check(not env.Roles.player_has_any_permission(players.bob), "any of none is false") + check(env.Roles.player_has_all_permission(players.alice, jail, kill), "all passes when every one is granted") + 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") +end) -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") +test("role permission and player methods", function(env) + local players = setup(env) + check(env.R("Moderator"):has_permission("exp_scenario.command.kill"), "has_permission granted") + check(not env.R("Regular"):has_permission("exp_scenario.command.jail"), "has_permission not granted") + check(env.R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything") + check(env.R("Moderator"):has_player(players.alice), "has_player for a held role") + check(not env.R("Moderator"):has_player(players.bob), "has_player for a role not held") + check(env.R("Player"):has_player(players.carol), "everyone has the default role") +end) -return Env.results_json(env) +test("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") + check(not env.Roles.player_outranks(players.alice, players.alice), "nobody outranks themselves") + check(env.Roles.player_outranks(nil, players.alice), "the server outranks everyone") + check(not env.Roles.player_outranks(players.alice, nil), "nobody outranks the server") +end) + +return Env.finish() diff --git a/exp_roles/test/lua/sync.lua b/exp_roles/test/lua/sync.lua index dc84edf8..ea710692 100644 --- a/exp_roles/test/lua/sync.lua +++ b/exp_roles/test/lua/sync.lua @@ -1,91 +1,118 @@ --- 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 Test = Env.Test +local test, check, eq = Test.test, Test.check, Test.eq -local alice = env.add_player("alice", 1) -local carol = env.add_player("carol", 3) +--- 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), + } + 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.Roles.set_emit_events(true) + return players +end -local admin_state = {} -Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) - admin_state[player.name] = state +test("initialise applies triggers and raises empty events", function(env) + setup(env) + check(env.admin_state.alice == true and env.admin_state.carol == false, + "triggers run for connected players") + check(#env.events == 2, "the event is raised once per connected player") + check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "the events are empty") + check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing") end) -env.initialise{ - Env.assignment("alice", { 5 }), - Env.assignment("zed", { 5, 6 }), -- has never joined this map -} -Roles.set_emit_events(true) +test("controller assignments apply and are announced", function(env) + local players = setup(env) + env.reset_log() + env.Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) } + check(env.R("Moderator"):has_player(players.carol), "the assignment applies") + check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event is raised") + check(env.events[1].data.by_player_index == 0, "controller changes have no by player") + check(#env.printed == 1 and env.printed[1][4] == "", "the change is announced by the server") + check(env.admin_state.carol == true, "triggers follow the assignment") -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") + env.reset_log() + env.Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } } + check(not env.R("Moderator"):has_player(players.carol), "a removal applies") + check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "the removal raises the event") + check(env.admin_state.carol == false, "triggers follow the removal") +end) --- 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") +test("changes for players not on this map raise nothing", function(env) + setup(env) + env.reset_log() + env.Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) } + check(#env.events == 0 and #env.printed == 0, "no event and no announcement") +end) -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") +test("role updates apply to their holders", function(env) + setup(env) + env.reset_log() + env.Roles.receive_role_updates{ + { id = 6, 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].data.assigned == 0, "role change events are empty") -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") + env.Roles.receive_role_updates{ + { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true }, + } + check(env.Roles.get_role(6) == nil, "a deleted role is removed") +end) --- 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") +test("the default role follows is_default", function(env) + local players = setup(env) + env.Roles.receive_role_updates{ + { id = 1, name = "Player", permissions = {}, meta = { id = 0, order = 5 } }, + { id = 8, name = "Guest", permissions = {}, meta = { id = 0, order = 7 }, is_default = true }, + } + check(env.Roles.get_default_role() == env.R("Guest"), "the new default role is known") + check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Guest" }), "players pick up the new default") +end) -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") +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 }) -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") + local sd = env.Roles._script_data() + check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise") --- 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") + env.reset_log() + env.initialise{ Env.assignment("alice", { 5 }) } + check(sd.pending.carol == nil, "pending roles are cleared") + check(not env.R("Moderator"):has_player(players.carol), "an unconfirmed role is given up") + check(eq(sd.local_players.carol, { 7 }), "local only roles are kept") + check(#env.sent == 0, "initialise sends nothing") +end) --- 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") +test("nothing is sent while emit is disabled", 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.R("Regular"):has_player(players.alice), "the assignment still applies locally") +end) --- 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") +test("roles are usable after load and triggers run on join", function(env) + setup(env) + env.Roles.on_load() + check(env.R("Moderator"):is_higher_than(env.R("Regular")), "roles are usable after load") -return Env.results_json(env) + env.admin_state.alice = nil + env.Roles.on_player_joined_game{ player_index = 1 } + check(env.admin_state.alice ~= nil, "triggers run when a player joins") +end) + +return Env.finish() diff --git a/exp_roles/test/messages.test.js b/exp_roles/test/messages.test.js index ff1c774a..1d865f5a 100644 --- a/exp_roles/test/messages.test.js +++ b/exp_roles/test/messages.test.js @@ -1,31 +1,83 @@ "use strict"; const t = require("tap"); -const { Value } = require("@sinclair/typebox/value"); const messages = require("../dist/node/messages"); +const { testMatrix, testRoundTripJsonSerialisable } = require("../../test/common"); -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"); -} +const fullMeta = new messages.RoleMetaRecord( + 7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false, +); + +t.test("RoleColor", subtest => { + testRoundTripJsonSerialisable(messages.RoleColor, testMatrix( + [0, 255], // r + [0, 128], // g + [0, 1], // b + )); + subtest.pass("round trips"); + subtest.end(); +}); 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, + testRoundTripJsonSerialisable(messages.RoleMetaRecord, testMatrix( + [7], // id + [3], // order + [0, 1], // priority + ["", "Mod"], // shortHand + ["", "[Mod]"], // tag + [null, new messages.RoleColor(1, 2, 3)], // color + [null, 3600000], // autoAssignOnlineTimeMs + [false, true], // blockAutoAssign + [0, 12345], // updatedAtMs + [false, true], // isDeleted )); + subtest.pass("round trips"); 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, + testRoundTripJsonSerialisable(messages.RoleRecord, testMatrix( + [7], // id + ["Moderator"], // name + [[], ["a.b", "c.d"]], // permissions + [new messages.RoleMetaRecord(7, 3), fullMeta], // meta + [false, true], // isDefault + [0, 12345], // updatedAtMs + [false, true], // isDeleted )); + subtest.pass("round trips"); subtest.end(); }); t.test("AssignmentRecord", subtest => { - roundTrip(subtest, messages.AssignmentRecord, new messages.AssignmentRecord("alice", new Set([5, 6]), 12345)); + testRoundTripJsonSerialisable(messages.AssignmentRecord, testMatrix( + ["alice"], // name + [new Set(), new Set([5, 6])], // roleIds + [0, 12345], // updatedAtMs + [false, true], // isDeleted + )); + subtest.pass("round trips"); + subtest.end(); +}); + +t.test("update events and requests", subtest => { + const record = new messages.RoleRecord(7, "Moderator", ["a.b"], fullMeta, true, 12345); + const assignment = new messages.AssignmentRecord("alice", new Set([5]), 12345); + + testRoundTripJsonSerialisable(messages.RoleUpdatedEvent, testMatrix( + [[], [record]], // updates + )); + testRoundTripJsonSerialisable(messages.AssignmentUpdatedEvent, testMatrix( + [[], [assignment]], // updates + )); + testRoundTripJsonSerialisable(messages.RoleMetaUpdateRequest, testMatrix( + [new messages.RoleMetaRecord(7, 3), fullMeta], // meta + )); + testRoundTripJsonSerialisable(messages.AssignmentUpdateRequest, testMatrix( + ["alice"], // name + [[], [5]], // assign + [[], [6]], // unassign + )); + subtest.pass("round trips"); subtest.end(); }); diff --git a/exp_roles/test/module.test.js b/exp_roles/test/module.test.js index 9bca4e1a..8f11dc5c 100644 --- a/exp_roles/test/module.test.js +++ b/exp_roles/test/module.test.js @@ -1,8 +1,11 @@ "use strict"; +const path = require("node:path"); const t = require("tap"); -const { reportLuaTests } = require("./helpers/lua"); +const { reportLuaTests } = require("../../test/lua/runner"); -// Each file runs in its own lua state with a fresh copy of the module +const envFile = path.join(__dirname, "lua", "env.lua"); + +// Each file runs in its own lua state, and each test in a fresh environment for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) { - t.test(file, subtest => reportLuaTests(subtest, file)); + t.test(file, subtest => reportLuaTests(subtest, envFile, path.join(__dirname, "lua", file))); } diff --git a/test/common.js b/test/common.js new file mode 100644 index 00000000..eb75a3f2 --- /dev/null +++ b/test/common.js @@ -0,0 +1,41 @@ +"use strict"; +const assert = require("assert").strict; +const { compile } = require("@clusterio/lib"); + +/** + * Generate a flat array of tests from a matrix of inputs. + * + * @template {any[][]} T + * @param {T} arrays - A list of arrays representing the different values for each argument + * @returns {Array<{ [K in keyof T]: T[K][number] }>} + * An array of tuples, each containing one value from each input array. + */ +function testMatrix(...arrays) { + return arrays.reduce((acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])), [[]]); +} + +/** + * Test that a class is round trip json serialisable across multiple test cases. + * + * @template {any[]} T - Constructor arguments + * @param {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods. + * @param {T[]} tests - The tests inputs to pass to the class constructor. + */ +function testRoundTripJsonSerialisable(Class, tests) { + const validate = compile(Class.jsonSchema); + for (const test of tests) { + const original = new Class(...test); + const serialised = JSON.stringify(original); + const jsonObject = JSON.parse(serialised); + const reconstructed = Class.fromJSON(jsonObject); + assert.deepEqual(reconstructed, original, JSON.stringify(test)); + if (!validate(jsonObject)) { + throw validate.errors; + } + } +} + +module.exports = { + testMatrix, + testRoundTripJsonSerialisable, +}; diff --git a/test/lua/framework.lua b/test/lua/framework.lua new file mode 100644 index 00000000..f5193b50 --- /dev/null +++ b/test/lua/framework.lua @@ -0,0 +1,109 @@ +--[[-- Test framework for plugin module tests +Test files declare named tests with `Test.test(name, function(env) ... end)`. +The runner creates a fresh environment for every test, so tests are +independent and can not leak state into each other. +]] + +local Framework = {} + +--- Create a test registry, one is made per test file +function Framework.new() + --- @class Test + local Test = { + tests = {}, -- { name, fn } in declaration order + results = {}, -- { name, ok, detail? } accumulated across tests + } + + local current_test --- @type string? + + --- Declare a named test, its function receives a fresh environment + --- @param name string + --- @param fn fun(env: table) + function Test.test(name, fn) + Test.tests[#Test.tests + 1] = { name = name, fn = fn } + end + + --- Record one check within the current test + --- @param ok any Truthy when the check passed + --- @param name string + --- @param detail string? + function Test.check(ok, name, detail) + Test.results[#Test.results + 1] = { + name = current_test and (current_test .. ": " .. name) or name, + ok = not not ok, + detail = detail, + } + end + + --- Shallow array equality + function Test.eq(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + if #a ~= #b then return false end + for i = 1, #a do + if a[i] ~= b[i] then return false end + end + return true + end + + --- Recursive table equality, keys checked from both sides + function Test.deep_eq(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return a == b end + for key, value in pairs(a) do + if not Test.deep_eq(value, b[key]) then return false end + end + for key in pairs(b) do + if a[key] == nil then return false end + end + return true + end + + --- Names of an array of roles + function Test.names(roles) + local rtn = {} + for index, role in ipairs(roles) do + rtn[index] = role.name + end + return rtn + end + + --- Sorted copy of an array of strings + function Test.sorted(list) + local rtn = {} + for index, value in ipairs(list) do rtn[index] = value end + table.sort(rtn) + return rtn + end + + --- Run every declared test against a fresh environment + --- @param make_env fun(): table + function Test.run(make_env) + for _, test in ipairs(Test.tests) do + current_test = test.name + local ok, err = pcall(test.fn, make_env()) + if not ok then + Test.check(false, "did not error", tostring(err)) + end + end + current_test = nil + end + + --- Encode the results as JSON for the javascript side + function Test.results_json() + local function escape(value) + return (value:gsub('[%c"\\]', function(c) + return string.format("\\u%04x", c:byte()) + end)) + end + + local parts = {} + for index, result in ipairs(Test.results) do + local detail = result.detail and string.format(',"detail":"%s"', escape(result.detail)) or "" + parts[index] = string.format('{"name":"%s","ok":%s%s}', escape(result.name), tostring(result.ok), detail) + end + return "[" .. table.concat(parts, ",") .. "]" + end + + return Test +end + +return Framework diff --git a/exp_roles/test/helpers/lua.js b/test/lua/runner.js similarity index 53% rename from exp_roles/test/helpers/lua.js rename to test/lua/runner.js index 8f8dddde..51019609 100644 --- a/exp_roles/test/helpers/lua.js +++ b/test/lua/runner.js @@ -3,28 +3,30 @@ 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. + * gets an independent set of stubs. Three chunks run in order, each taking one + * argument and returning one value: * - * @param {string} name - File in test/lua to run, such as "lookup.lua". + * 1. The plugin's env.lua, receiving this directory so it can load the shared + * stubs and framework, returning its environment module. + * 2. The test file, receiving the environment module, declaring its tests. + * 3. The tests then run, each against a fresh environment, and the results + * are returned as JSON. + * + * @param {string} envFile - Absolute path of the plugin's test/lua/env.lua. + * @param {string} testFile - Absolute path of the lua test file to run. * @returns {{ name: string, ok: boolean, detail?: string }[]} */ -function runLuaTests(name) { +function runLuaTests(envFile, testFile) { 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 load = (file) => { const code = fs.readFileSync(file); - const status = lauxlib.luaL_loadbuffer(L, code, code.length, to_luastring(`@${chunkName}`)); + const status = lauxlib.luaL_loadbuffer(L, code, code.length, to_luastring(`@${file}`)); if (status !== lua.LUA_OK) { throw new Error(to_jsstring(lua.lua_tostring(L, -1))); } @@ -35,13 +37,12 @@ function runLuaTests(name) { } }; - // 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)); + load(envFile); + lua.lua_pushstring(L, to_luastring(__dirname)); call(); // The environment stays on the stack and is passed to the test chunk - load(path.join(luaRoot, name), `test/lua/${name}`); + load(testFile); lua.lua_insert(L, -2); call(); @@ -49,10 +50,10 @@ function runLuaTests(name) { 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`); +/** Report the results of a lua test file through a tap test object. */ +function reportLuaTests(t, envFile, testFile) { + const results = runLuaTests(envFile, testFile); + t.ok(results.length > 0, `${path.basename(testFile)} produced results`); for (const result of results) { t.ok(result.ok, result.name, result.detail ? { detail: result.detail } : {}); } diff --git a/test/lua/stubs.lua b/test/lua/stubs.lua new file mode 100644 index 00000000..5ed3fa48 --- /dev/null +++ b/test/lua/stubs.lua @@ -0,0 +1,118 @@ +--[[-- Generic factorio stubs for plugin module tests +Provides the surface a clusterio module touches, recording what the module does +to it. Every stub raises an error when an unimplemented property is read, which +mirrors the game api and catches mistakes in tests early. + +Plugins compose these from their own test/lua/env.lua, adding stubs of their +own through `stubs.requires` and `Stubs.strict`. +]] + +local Stubs = {} + +--- Give a table an index metamethod which raises on unknown properties +--- @generic T : table +--- @param name string Shown in the error message +--- @param tbl T +--- @param allowed_nil string[]? Properties which may be read while unset +--- @return T +function Stubs.strict(name, tbl, allowed_nil) + local allowed = {} + for _, key in pairs(allowed_nil or {}) do + allowed[key] = true + end + + return setmetatable(tbl, { + __index = function(_, key) + if allowed[key] then return nil end + error(name .. " does not implement: " .. tostring(key), 2) + end, + }) +end + +--- Create an independent set of stubs, installed as the lua globals +function Stubs.new() + local stubs = { + events = {}, -- events raised through script.raise_event + printed = {}, -- game.print and player.print messages + sent = {}, -- payloads sent through the clusterio api + sounds = {}, -- sounds played to players + } + + local next_event_id = 100 + script = Stubs.strict("script", { + generate_event_name = function() + next_event_id = next_event_id + 1 + return next_event_id + end, + raise_event = function(id, data) + stubs.events[#stubs.events + 1] = { id = id, data = data } + end, + register_metatable = function() end, + }) + + defines = Stubs.strict("defines", { + events = Stubs.strict("defines.events", { + on_player_joined_game = 1, + on_multiplayer_init = 2, + }), + }) + + local players_by_name, players_by_index, connected = {}, {}, {} + game = Stubs.strict("game", { + tick = 1, + players = players_by_name, + connected_players = connected, + print = function(message) stubs.printed[#stubs.printed + 1] = message end, + get_player = function(key) return players_by_name[key] or players_by_index[key] end, + }, { "player" }) + + --- Add a player to the stubbed game + function stubs.add_player(name, index, is_connected) + local player = Stubs.strict("LuaPlayer " .. name, { + name = name, + index = index, + connected = is_connected ~= false, + valid = true, + play_sound = function(opts) + stubs.sounds[#stubs.sounds + 1] = name .. ":" .. opts.path + end, + print = function(message) + stubs.printed[#stubs.printed + 1] = { to = name, message } + end, + }) + players_by_name[name] = player + players_by_index[index] = player + if player.connected then connected[#connected + 1] = player end + return player + end + + --- A player object which represents the server + stubs.server = Stubs.strict("server player", { index = 0, name = "" }) + + --- Modules resolved by the stubbed require, extend before loading the module + stubs.requires = { + ["modules/clusterio/api"] = Stubs.strict("clusterio api", { + send_json = function(channel, data) + stubs.sent[#stubs.sent + 1] = { channel = channel, data = data } + end, + }), + ["modules/clusterio/compat"] = Stubs.strict("clusterio compat", { script_data = {} }), + ["modules/exp_util/async"] = Stubs.strict("exp_util async", { + register = function(callback) + return function(...) return callback(...) end + end, + }), + } + require = function(name) + return assert(stubs.requires[name], "Unexpected require: " .. name) + end + + --- Forget everything recorded so far + function stubs.reset_log() + stubs.events, stubs.printed, stubs.sent, stubs.sounds = {}, {}, {}, {} + end + + return stubs +end + +return Stubs