Address review on the suite and instance tests

- The instance tests run against a real Instance with a real InstanceConfig,
  matching the controller tests: the spies are sendTo and the server, which
  record what leaves the instance, so sendRcon passes through the real script
  command gate. The plugin's config fields are registered so the sync mode is
  a real field.
- Shallow eq is gone and eq compares recursively; the suite is created with
  Framework.suite(extend_env), which hands the extension a fresh set of stubs
  per test, so the plugin's env.lua is only the extension.
- add_player assigns continuous indexes itself.
- The lua fixtures are referenced by role name: assignments take names, and
  ids in expectations come from the role object. The name lookup in env.R
  uses a fixture index rather than searching the roles.
- Test names format functions as name() and methods as .name().
- Branch coverage filled in: sort tie breaking, the highest role assertion
  when a player has no roles, has all for the server, the by player default
  from game.player, deleted assignment records on initialise, set_emit_events
  with no argument, printing to a role with no holders, role property update
  validation, the assignment subscription replay, unassignment over the ipc,
  and a refused removal having nothing to roll back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bbassie
2026-08-25 21:11:58 +00:00
co-authored by Claude Fable 5
parent e6552a39be
commit 3218351db6
12 changed files with 340 additions and 234 deletions
+33 -8
View File
@@ -52,7 +52,7 @@ async function startPlugin(t2, { roles = [] } = {}) {
const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions)); const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions));
t.test("class ControllerPlugin", t2 => { 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")] }); 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"); 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"); 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, { const { plugin, controller } = await startPlugin(t3, {
roles: [role(1, "Player"), role(5, "Moderator", ["exp_scenario.command.kill"])], 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"); 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")] }); const { plugin, controller, state } = await startPlugin(t3, { roles: [role(1, "Player")] });
state.broadcasts.length = 0; 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")] }); const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player")] });
controller.roles.set(role(5, "Moderator")); controller.roles.set(role(5, "Moderator"));
const updatedAtMs = plugin.buildRoleRecord(controller.roles.get(5)).updatedAtMs; 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"); 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")] }); 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("alice").set("roleIds", new Set([lib.Role.DefaultPlayerRoleId, 5]));
controller.users.createUser("bob"); 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"); 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, { const { plugin, controller, state } = await startPlugin(t3, {
roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")], 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"); 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, { const { plugin, controller } = await startPlugin(t3, {
roles: [role(1, "Player"), role(6, "Regular"), role(7, "Jail")], 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"); 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, { const { plugin, controller } = await startPlugin(t3, {
roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")], roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")],
}); });
+103 -64
View File
@@ -1,14 +1,32 @@
"use strict"; "use strict";
const t = require("tap"); const t = require("tap");
const lib = require("@clusterio/lib"); const lib = require("@clusterio/lib");
const { Instance } = require("@clusterio/host");
const { InstancePlugin } = require("../dist/node/instance"); const { InstancePlugin } = require("../dist/node/instance");
const { plugin: pluginDeclaration } = require("../dist/node/index");
const messages = require("../dist/node/messages"); 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: () => {} }; const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
// Subscribing validates event names against the link registry class TestConnector extends lib.BaseConnector {
lib.Link.register(messages.RoleUpdatedEvent); constructor() {
lib.Link.register(messages.AssignmentUpdatedEvent); super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 }));
this.valid = true;
this.connected = true;
this.hasSession = true;
}
send() {}
}
const sampleRoles = () => [ const sampleRoles = () => [
new messages.RoleRecord(1, "Player", ["exp_scenario.gui.readme"], new messages.RoleMetaRecord(1, 2), true), 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]))]; 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() } = {}) { 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 state = { sent: [], rcons: [], warnings: [], failNext: null };
const instance = { instance.server = {
logger,
config: { get: field => (field === "exp_roles.sync_mode" ? syncMode : undefined) },
handle: () => {}, handle: () => {},
server: { handle: () => {} }, sendRcon: async command => {
sendTo: async (dst, request) => { state.rcons.push(command);
state.sent.push(request); return "";
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), };
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, {}); const plugin = new InstancePlugin({ name: "exp_roles" }, instance, {});
@@ -55,92 +83,103 @@ function decodeRcon(command) {
} }
t.test("class InstancePlugin", t2 => { t.test("class InstancePlugin", t2 => {
t2.test("onStart subscribes and initialises the lua module", async t2 => { t2.test(".onStart() subscribes and initialises the lua module", async t3 => {
const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" });
await plugin.onStart(); await plugin.onStart();
const subscribed = state.sent.filter(request => request instanceof lib.SubscriptionRequest); const subscribed = state.sent.filter(request => request instanceof lib.SubscriptionRequest);
t2.strictSame(subscribed.length, 2, "roles and assignments are subscribed to"); t3.strictSame(subscribed.length, 2, "roles and assignments are subscribed to");
t2.ok(state.sent.some(request => request instanceof messages.RoleListRequest), "the roles are requested"); t3.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.ok(
state.sent.some(request => request instanceof messages.AssignmentListRequest),
"the assignments are requested",
);
const initialise = decodeRcon(state.rcons[0]); const initialise = decodeRcon(state.rcons[0]);
t2.strictSame(initialise.receiver, "initialise", "the lua module is initialised"); t3.strictSame(initialise.receiver, "initialise", "the lua module is initialised");
t2.strictSame(initialise.payload.permission_names, ["exp_scenario.gui.readme", "core.admin"], t3.strictSame(initialise.payload.permission_names, ["exp_scenario.gui.readme", "core.admin"],
"each permission name is sent once"); "each permission name is sent once");
t2.strictSame(initialise.payload.roles[1].permissions, [0, 1], "roles reference permissions by index"); t3.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.assignments, [{ name: "alice", role_ids: [5] }], "the assignments are sent");
const emit = decodeRcon(state.rcons[1]); const emit = decodeRcon(state.rcons[1]);
t2.strictSame([emit.receiver, emit.payload], ["set_emit_events", false], "enabled does not emit changes back"); t3.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(state.warnings.length, 0, "no warnings with a default role");
}); });
t2.test("onStart with bidirectional emits changes back", async t2 => { t2.test(".onStart() with bidirectional emits changes back", async t3 => {
const { plugin, state } = await startPlugin(t2); const { plugin, state } = await startPlugin(t3);
await plugin.onStart(); await plugin.onStart();
const emit = decodeRcon(state.rcons[state.rcons.length - 1]); 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 => { t2.test(".onStart() with disabled does nothing", async t3 => {
const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" });
await plugin.onStart(); await plugin.onStart();
t2.strictSame(state.sent.length, 0, "nothing is sent"); t3.strictSame(state.sent.length, 0, "nothing is sent");
t2.strictSame(state.rcons.length, 0, "no commands are run"); 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 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(); 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 => { t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() forward to lua", async t3 => {
const { plugin, state } = await startPlugin(t2); const { plugin, state } = await startPlugin(t3);
await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles()));
const roles = decodeRcon(state.rcons[0]); const roles = decodeRcon(state.rcons[0]);
t2.strictSame(roles.receiver, "receive_role_updates", "role updates are forwarded"); t3.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.payload[0].name, "Player", "the records are sent in plain form");
await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments()));
const assignments = decodeRcon(state.rcons[1]); 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 => { t2.test(".handleRoleUpdatedEvent() and .handleAssignmentUpdatedEvent() are gated while disabled", async t3 => {
const { plugin, state } = await startPlugin(t2, { syncMode: "disabled" }); const { plugin, state } = await startPlugin(t3, { syncMode: "disabled" });
await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles())); await plugin.handleRoleUpdatedEvent(new messages.RoleUpdatedEvent(sampleRoles()));
await plugin.handleAssignmentUpdatedEvent(new messages.AssignmentUpdatedEvent(sampleAssignments())); 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 => { t2.test(".handleAssignmentUpdateIPC() sends the change and rolls back a refusal", async t3 => {
const { plugin, state } = await startPlugin(t2); const { plugin, state } = await startPlugin(t3);
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined });
const request = state.sent[0]; const request = state.sent[0];
t2.ok(request instanceof messages.AssignmentUpdateRequest, "the change is sent as a request"); t3.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.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"); state.failNext = new Error("User 'alice' does not exist");
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined });
const rollback = decodeRcon(state.rcons[0]); const rollback = decodeRcon(state.rcons[0]);
t2.strictSame(rollback.receiver, "reject_assignment", "the refusal is rolled back in lua"); t3.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.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 => { t2.test(".handleAssignmentUpdateIPC() is gated unless bidirectional", async t3 => {
const { plugin, state } = await startPlugin(t2, { syncMode: "enabled" }); const { plugin, state } = await startPlugin(t3, { syncMode: "enabled" });
await plugin.handleAssignmentUpdateIPC({ name: "alice", assign: [5], unassign: undefined }); 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 => { t2.test(".onInstanceConfigFieldChanged() toggles emit with the sync mode", async t3 => {
const { plugin, state } = await startPlugin(t2); const { plugin, state } = await startPlugin(t3);
await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional"); await plugin.onInstanceConfigFieldChanged("exp_roles.sync_mode", "enabled", "bidirectional");
const emit = decodeRcon(state.rcons[0]); 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(); t2.end();
}); });
+62 -50
View File
@@ -1,36 +1,37 @@
--- Tests for the assignment methods of module/control.lua --- 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 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 --- alice is a moderator and bob a regular, with changes sent to the controller
local function setup(env) local function setup(env)
local players = { local players = {
alice = env.add_player("alice", 1), alice = env.add_player("alice"),
bob = env.add_player("bob", 2), bob = env.add_player("bob"),
} }
env.initialise{ env.initialise{
env.assignment("alice", { 5 }), env.assignment("alice", { "Moderator" }),
env.assignment("bob", { 6 }), env.assignment("bob", { "Regular" }),
} }
env.Roles.set_emit_events(true) env.Roles.set_emit_events(true)
env.reset_log() env.reset_log()
return players return players
end 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) local players = setup(env)
env.R("Moderator"):assign(players.bob, { by_player_name = "alice" }) local moderator = env.R("Moderator")
deep_eq(env.sent, { moderator:assign(players.bob, { by_player_name = "alice" })
{ channel = "exp_roles:assignment_update", data = { name = "bob", assign = { 5 } } }, eq(env.sent, {
{ channel = "exp_roles:assignment_update", data = { name = "bob", assign = { moderator.id } } },
}, "the assignment is sent to the controller") }, "the assignment is sent to the controller")
check(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation") check(moderator:has_player(players.bob), "the role applies before confirmation")
deep_eq(env.events, { eq(env.events, {
{ {
name = env.Roles.events.on_player_roles_changed, name = env.Roles.events.on_player_roles_changed,
tick = 1, tick = 1,
player_index = 2, player_index = players.bob.index,
by_player_index = 1, by_player_index = players.alice.index,
assigned = { 5 }, assigned = { moderator.id },
unassigned = {}, unassigned = {},
}, },
}, "the event carries the change") }, "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") eq(env.sounds, { "bob:utility/achievement_unlocked" }, "the assign sound plays")
local sd = env.Roles._script_data() local sd = env.Roles._script_data()
eq(sd.local_players.bob, { 5 }, "the role is held locally") eq(sd.local_players.bob, { moderator.id }, "the role is held locally")
eq(sd.pending.bob, { 5 }, "the role is pending") eq(sd.pending.bob, { moderator.id }, "the role is pending")
env.reset_log() 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") check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op")
end) 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) local players = setup(env)
env.R("Moderator"):assign(players.bob) env.R("Moderator"):assign(players.bob)
env.reset_log() 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") check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing")
local sd = env.Roles._script_data() 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") check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation")
end) end)
test("role:unassign removes a synced role", function(env) test(".unassign() removes a synced role", function(env)
local players = setup(env) local players = setup(env)
env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true }) local regular = env.R("Regular")
deep_eq(env.sent, { regular:unassign(players.bob, { by_player_name = "alice", silent = true })
{ channel = "exp_roles:assignment_update", data = { name = "bob", unassign = { 6 } } }, eq(env.sent, {
{ channel = "exp_roles:assignment_update", data = { name = "bob", unassign = { regular.id } } },
}, "the unassignment is sent to the controller") }, "the unassignment is sent to the controller")
check(not env.R("Regular"):has_player(players.bob), "the role is removed locally") check(not regular:has_player(players.bob), "the role is removed locally")
deep_eq(env.events, { eq(env.events, {
{ {
name = env.Roles.events.on_player_roles_changed, name = env.Roles.events.on_player_roles_changed,
tick = 1, tick = 1,
player_index = 2, player_index = players.bob.index,
by_player_index = 1, by_player_index = players.alice.index,
assigned = {}, assigned = {},
unassigned = { 6 }, unassigned = { regular.id },
}, },
}, "the event carries the change") }, "the event carries the change")
check(#env.printed == 0, "silent suppresses the announcement") check(#env.printed == 0, "silent suppresses the announcement")
eq(env.sounds, { "bob:utility/game_lost" }, "the unassign sound plays") eq(env.sounds, { "bob:utility/game_lost" }, "the unassign sound plays")
end) end)
test("reject_assignment rolls the assignment back", function(env) test("reject_assignment() rolls the assignment back", function(env)
local players = setup(env) local players = setup(env)
env.R("Jail"):assign(players.alice) local jail = env.R("Jail")
check(env.R("Jail"):has_player(players.alice), "the role applies before rejection") jail:assign(players.alice)
check(jail:has_player(players.alice), "the role applies before rejection")
env.reset_log() env.reset_log()
env.Roles.reject_assignment{ name = "alice", role_ids = { 7 } } env.Roles.reject_assignment{ name = "alice", role_ids = { jail.id } }
check(not env.R("Jail"):has_player(players.alice), "the rejected role is rolled back") 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") 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, name = env.Roles.events.on_player_roles_changed,
tick = 1, tick = 1,
player_index = 1, player_index = players.alice.index,
by_player_index = 0, by_player_index = 0,
assigned = {}, assigned = {},
unassigned = { 7 }, unassigned = { jail.id },
}, },
}, "the rollback raises the event") }, "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") check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state")
end) 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) 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.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() local sd = env.Roles._script_data()
check(sd.pending.alice == nil, "the role is not pending") check(sd.pending.alice == nil, "the role is not pending")
eq(sd.local_players.alice, { 6 }, "the role is held locally") eq(sd.local_players.alice, { regular.id }, "the role is held locally")
env.Roles.receive_assignment_updates{ env.assignment("alice", { 5 }) } env.Roles.receive_assignment_updates{ env.assignment("alice", { "Moderator" }) }
check(env.R("Regular"):has_player(players.alice), "the role survives controller updates") check(regular:has_player(players.alice), "the role survives controller updates")
env.reset_log() env.reset_log()
env.R("Regular"):unassign(players.alice, { local_only = true }) regular:unassign(players.alice, { local_only = true })
check(not env.R("Regular"):has_player(players.alice) and #env.sent == 0, "removed without sync") check(not regular:has_player(players.alice) and #env.sent == 0, "removed without sync")
end) 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) local players = setup(env)
env.R("Jail"):assign(players.alice, { by_player_name = "<server>", silent = true }) local jail = env.R("Jail")
jail:assign(players.alice, { by_player_name = "<server>", silent = true })
eq(Suite.names(env.Roles.get_player_roles(players.alice)), { "Jail" }, "only the jail role applies") 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(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.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_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(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") check(#env.events[1].unassigned == 0, "the suppressed roles are not listed as unassigned")
env.reset_log() env.reset_log()
env.R("Jail"):unassign(players.alice, { by_player_name = "<server>", silent = true }) jail:unassign(players.alice, { by_player_name = "<server>", silent = true })
eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" },
"unjail restores the roles") "unjail restores the roles")
check(#env.events[1].assigned == 0, "the restored roles are not listed as assigned") 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) end)
test("role:assign ignores the server", function(env) test(".assign() ignores the server", function(env)
setup(env) setup(env)
env.R("Moderator"):assign(env.server) env.R("Moderator"):assign(env.server)
check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op") check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op")
+22 -14
View File
@@ -1,8 +1,7 @@
--[[-- Test environment for module/control.lua --[[-- Test environment for module/control.lua
The module specific extension between the shared stubs and the test files: it 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 extends each environment with a fresh copy of the roles module and the
module and the fixtures below, and hands each test file a suite built around fixtures below, and hands each test file a suite built around that.
that factory.
Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite
returned here becomes `...` in each test file. 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 source = assert(debug.getinfo(1, "S")).source
local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$")) local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$"))
local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))()
local Framework = assert(loadfile(shared_root .. "/framework.lua"))() local Framework = assert(loadfile(shared_root .. "/framework.lua"))()
--- Standard fixture: permission names sent once and referenced by zero based index --- Standard fixture: permission names sent once and referenced by zero based index
@@ -43,21 +41,33 @@ local function role_records()
} }
end end
--- Create an independent environment with a fresh copy of the roles module --- Fixture role ids by name, avoiding a search of the roles on every lookup
local function make_env() local role_ids = {}
local env = Stubs.new() 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 = assert(loadfile(plugin_root .. "/module/control.lua"))()
env.Roles.on_server_startup() 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) 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)) return (assert(env.Roles.get_role_by_name(name), "No role named " .. name))
end end
--- An assignment record as the controller would send it --- An assignment record as the controller would send it, roles by name
function env.assignment(name, role_ids) function env.assignment(player_name, role_names)
return { name = name, role_ids = role_ids } 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 end
--- Load the standard fixture and the given assignments --- Load the standard fixture and the given assignments
@@ -70,6 +80,4 @@ local function make_env()
end end
return env return env
end end)
return Framework.new_suite(make_env)
+18 -17
View File
@@ -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 --- alice and dave are moderators with dave offline, zed has never joined
local function setup(env) local function setup(env)
local players = { local players = {
alice = env.add_player("alice", 1), alice = env.add_player("alice"),
bob = env.add_player("bob", 2), bob = env.add_player("bob"),
dave = env.add_player("dave", 4, false), dave = env.add_player("dave", false),
} }
env.initialise{ env.initialise{
env.assignment("alice", { 5 }), env.assignment("alice", { "Moderator" }),
env.assignment("bob", { 6 }), env.assignment("bob", { "Regular" }),
env.assignment("dave", { 5 }), env.assignment("dave", { "Moderator" }),
env.assignment("zed", { 5 }), env.assignment("zed", { "Moderator" }),
} }
env.Roles.set_emit_events(true) env.Roles.set_emit_events(true)
return players return players
end 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) setup(env)
eq(Suite.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" }, eq(Suite.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" },
"every player given the role is listed") "every player given the role is listed")
end) 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) local players = setup(env)
env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) 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() 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(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") eq(Suite.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }, "the player is counted once")
end) 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) setup(env)
local mod = env.R("Moderator") local moderator = env.R("Moderator")
eq(Suite.sorted(Suite.names(mod:get_players())), { "alice", "dave" }, "players are limited to this map") eq(Suite.sorted(Suite.names(moderator: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(moderator:get_players(true)), { "alice" }, "filtered to connected players")
eq(Suite.names(mod:get_players(false)), { "dave" }, "filtered to offline players") eq(Suite.names(moderator:get_players(false)), { "dave" }, "filtered to offline players")
end) end)
test("role:print reaches online holders", function(env) test(".print() reaches online holders", function(env)
setup(env) setup(env)
env.reset_log() env.reset_log()
check(env.R("Moderator"):print("hello") == 1, "print returns the number of players reached") 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.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) 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) local players = setup(env)
env.R("Regular"):assign(players.alice, { silent = true, local_only = true }) env.R("Regular"):assign(players.alice, { silent = true, local_only = true })
env.reset_log() env.reset_log()
+18 -12
View File
@@ -2,42 +2,48 @@
local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua local Suite = ... --- The suite this file adds its tests to, see test/lua/framework.lua
local test, check, eq = Suite.test, Suite.check, Suite.eq 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{} env.initialise{}
check(env.Roles.get_role(6).name == "Regular", "the role is found by id") check(env.Roles.get_role(env.R("Regular").id) == env.R("Regular"), "the role is found by id")
check(env.Roles.get_role(env.R("Jail").id) == env.R("Jail"), "lookups return the same object")
check(env.Roles.get_role(9) == nil, "deleted roles are not known") check(env.Roles.get_role(9) == nil, "deleted roles are not known")
end) end)
test("get_role_by_name searches the roles", function(env) test("get_role_by_name() searches the roles", function(env)
env.initialise{} 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") check(env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known")
end) end)
test("get_roles returns every role", function(env) test("get_roles() returns every role", function(env)
env.initialise{} env.initialise{}
check(#env.Roles.get_roles() == 5, "every role is returned with deleted roles skipped") check(#env.Roles.get_roles() == 5, "every role is returned with deleted roles skipped")
end) 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{} env.initialise{}
eq(Suite.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }, eq(Suite.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" },
"roles follow their order") "roles follow their order")
end) 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{} env.initialise{}
local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") } 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") 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) end)
test("get_default_role follows is_default", function(env) test("get_default_role() follows is_default", function(env)
env.initialise{} env.initialise{}
check(env.Roles.get_default_role() == env.R("Player"), "the default role is known") check(env.Roles.get_default_role() == env.R("Player"), "the default role is known")
end) 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{} env.initialise{}
eq(Suite.sorted(Suite.names(env.Roles.get_higher_roles(env.R("Regular")))), eq(Suite.sorted(Suite.names(env.Roles.get_higher_roles(env.R("Regular")))),
{ "Cluster Admin", "Moderator", "Regular" }, "higher roles") { "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") { "Jail", "Regular" }, "lower roles")
end) 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{} env.initialise{}
check(env.R("Moderator"):is_higher_than(env.R("Player")), "a lower order is more privileged") 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(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") check(not env.R("Moderator"):is_higher_than(env.R("Moderator")), "a role is not higher than itself")
end) end)
test("initialise decodes the role records", function(env) test("initialise() decodes the role records", function(env)
env.initialise{} env.initialise{}
check(env.R("Cluster Admin").short_hand == "SYS", "short hand decoded") 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").short_hand == "Moderator", "short hand falls back to the name")
+22 -13
View File
@@ -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 --- alice is a moderator, bob is a regular, and carol has only the default role
local function setup(env) local function setup(env)
local players = { local players = {
alice = env.add_player("alice", 1), alice = env.add_player("alice"),
bob = env.add_player("bob", 2), bob = env.add_player("bob"),
carol = env.add_player("carol", 3), carol = env.add_player("carol"),
} }
env.initialise{ env.initialise{
env.assignment("alice", { 5 }), env.assignment("alice", { "Moderator" }),
env.assignment("bob", { 6 }), env.assignment("bob", { "Regular" }),
} }
return players return players
end 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) local players = setup(env)
eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }, eq(Suite.sorted(Suite.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" },
"held roles and the default role") "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") "a player with no roles has the default role")
end) 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) setup(env)
eq(Suite.names(env.Roles.get_player_roles(nil)), { "<server>" }, "nil is the server") eq(Suite.names(env.Roles.get_player_roles(nil)), { "<server>" }, "nil is the server")
eq(Suite.names(env.Roles.get_player_roles(env.server)), { "<server>" }, "index 0 is the server") eq(Suite.names(env.Roles.get_player_roles(env.server)), { "<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") check(not env.R("Player"):has_player(nil), "the server does not have the default role")
end) end)
test("get_player_highest_role", function(env) test("get_player_highest_role() returns the most privileged role", function(env)
local players = setup(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.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") check(env.Roles.get_player_highest_role(players.carol) == env.R("Player"), "the default role when none are held")
end) 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) 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.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(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") check(not env.Roles.player_has_permission(players.bob, "exp_scenario.command.jail"), "not granted")
end) 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 players = setup(env)
local jail, kill = "exp_scenario.command.jail", "exp_scenario.command.kill" 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(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(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_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_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) end)
test("role:has_permission", function(env) test(".has_permission()", function(env)
setup(env) setup(env)
check(env.R("Moderator"):has_permission("exp_scenario.command.kill"), "granted") 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(not env.R("Regular"):has_permission("exp_scenario.command.jail"), "not granted")
check(env.R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything") check(env.R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything")
end) end)
test("role:has_player", function(env) test(".has_player()", function(env)
local players = setup(env) local players = setup(env)
check(env.R("Moderator"):has_player(players.alice), "a held role") 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(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") check(env.R("Player"):has_player(players.carol), "everyone has the default role")
end) end)
test("player_outranks", function(env) test("player_outranks()", function(env)
local players = setup(env) local players = setup(env)
check(env.Roles.player_outranks(players.alice, players.bob), "a higher role outranks a lower one") 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.bob, players.alice), "a lower role does not outrank a higher one")
+44 -31
View File
@@ -1,61 +1,69 @@
--- Tests for the sync entry points of module/control.lua --- 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 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 --- alice is a moderator and carol has only the default role, both connected
local function setup(env) local function setup(env)
local players = { local players = {
alice = env.add_player("alice", 1), alice = env.add_player("alice"),
carol = env.add_player("carol", 3), carol = env.add_player("carol"),
} }
env.admin_state = {} env.admin_state = {}
env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state) env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state)
env.admin_state[player.name] = state env.admin_state[player.name] = state
end) end)
env.initialise{ env.initialise{
env.assignment("alice", { 5 }), env.assignment("alice", { "Moderator" }),
env.assignment("zed", { 5, 6 }), -- has never joined this map env.assignment("zed", { "Moderator", "Regular" }), -- has never joined this map
} }
env.Roles.set_emit_events(true) env.Roles.set_emit_events(true)
return players return players
end end
test("initialise applies triggers and raises empty events", function(env) test("initialise() applies triggers and raises empty events", function(env)
setup(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 == 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.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") check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing")
end) 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) local players = setup(env)
env.R("Moderator"):assign(players.carol) env.R("Moderator"):assign(players.carol)
env.R("Jail"):assign(players.carol, { silent = true, local_only = true }) env.R("Jail"):assign(players.carol, { silent = true, local_only = true })
local sd = env.Roles._script_data() 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.reset_log()
env.initialise{ env.assignment("alice", { 5 }) } env.initialise{ env.assignment("alice", { "Moderator" }) }
check(sd.pending.carol == nil, "pending roles are cleared") 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(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") check(#env.sent == 0, "initialise sends nothing")
end) end)
test("receive_assignment_updates applies controller changes", function(env) test("receive_assignment_updates() applies controller changes", function(env)
local players = setup(env) local players = setup(env)
env.reset_log() 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") 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, name = env.Roles.events.on_player_roles_changed,
tick = 1, tick = 1,
player_index = 3, player_index = players.carol.index,
by_player_index = 0, by_player_index = 0,
assigned = { 5 }, assigned = { env.R("Moderator").id },
unassigned = {}, unassigned = {},
}, },
}, "the event carries the change with no by player") }, "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.reset_log()
env.Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } } 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(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") check(env.admin_state.carol == false, "triggers follow the removal")
end) 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) setup(env)
env.reset_log() 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") check(#env.events == 0 and #env.printed == 0, "no event and no announcement")
end) end)
test("receive_role_updates applies to the holders", function(env) test("receive_role_updates() applies to the holders", function(env)
setup(env) setup(env)
env.reset_log() env.reset_log()
local regular_id = env.R("Regular").id
env.Roles.receive_role_updates{ 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.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 == 2, "a role change raises for every connected player")
check(#env.events[1].assigned == 0, "role change events are empty") check(#env.events[1].assigned == 0, "role change events are empty")
env.Roles.receive_role_updates{ 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) 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) local players = setup(env)
env.Roles.receive_role_updates{ 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 }, { 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(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") eq(Suite.names(env.Roles.get_player_roles(players.carol)), { "Guest" }, "players pick up the new default")
end) 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) local players = setup(env)
env.Roles.set_emit_events(false) env.Roles.set_emit_events(false)
env.reset_log() env.reset_log()
env.R("Regular"):assign(players.alice) 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") 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) 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) local players = setup(env)
env.R("Jail"):assign(players.carol, { silent = true, local_only = true }) env.R("Jail"):assign(players.carol, { silent = true, local_only = true })
env.save_load() 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") check(env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permission checks still answer")
end) end)
test("on_player_joined_game runs the triggers", function(env) test("on_player_joined_game() runs the triggers", function(env)
setup(env) local players = setup(env)
env.admin_state.alice = nil 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") check(env.admin_state.alice ~= nil, "triggers run when a player joins")
end) end)
+1 -1
View File
@@ -96,7 +96,7 @@ t.test("class AssignmentUpdateRequest", subtest => {
subtest.end(); 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 meta = id => new messages.RoleMetaRecord(id, id);
const encoded = messages.encodeRolesForLua([ const encoded = messages.encodeRolesForLua([
new messages.RoleRecord(1, "A", ["p.one", "p.two"], meta(1)), new messages.RoleRecord(1, "A", ["p.one", "p.two"], meta(1)),
+1 -1
View File
@@ -15,7 +15,7 @@ t.test("seedRoles grant only defined permissions", subtest => {
subtest.end(); 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])); const byName = new Map(seedRoles.map(role => [role.name, role]));
for (const role of seedRoles) { for (const role of seedRoles) {
if (role.parent === undefined) { if (role.parent === undefined) {
+13 -21
View File
@@ -1,11 +1,15 @@
--[[-- Test framework for plugin module tests --[[-- Test framework for plugin module tests
A suite is a collection of named tests. Test files declare them with 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 `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 or method it covers. Every test function receives a fresh environment: the
factory the suite was created with, so tests are independent and can not leak stubs extended by the function the suite was created with, so tests are
state into each other. 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 = {} local Framework = {}
--- Turn a value into a string for failure messages --- Turn a value into a string for failure messages
@@ -21,8 +25,8 @@ local function repr(value, depth)
end end
--- Create a suite of tests, one is made per test file --- Create a suite of tests, one is made per test file
--- @param make_env fun(): table Creates the environment given to each test --- @param extend_env fun(env: table): table Extends the stubs given to each test
function Framework.new_suite(make_env) function Framework.suite(extend_env)
--- @class Suite --- @class Suite
local Suite = { local Suite = {
tests = {}, -- { name, fn } in declaration order tests = {}, -- { name, fn } in declaration order
@@ -70,20 +74,6 @@ function Framework.new_suite(make_env)
end end
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 --- Recursive table equality, keys checked from both sides
local function deep_equal(a, b) local function deep_equal(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return a == b end if type(a) ~= "table" or type(b) ~= "table" then return a == b end
@@ -97,7 +87,7 @@ function Framework.new_suite(make_env)
end end
--- Check two values are equal, comparing tables recursively --- 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))) Suite.check(deep_equal(a, b), name, ("%s ~= %s"):format(repr(a, 1), repr(b, 1)))
end end
@@ -126,7 +116,9 @@ function Framework.new_suite(make_env)
function Suite.run() function Suite.run()
for _, test in ipairs(Suite.tests) do for _, test in ipairs(Suite.tests) do
current_test = test.name 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 if not ok then
Suite.fail("did not error", tostring(err)) Suite.fail("did not error", tostring(err))
end end
+3 -2
View File
@@ -95,8 +95,9 @@ function Stubs.new()
get_player = function(key) return players[key] end, get_player = function(key) return players[key] end,
}, { "player" }) }, { "player" })
--- Add a player to the stubbed game --- Add a player to the stubbed game, indexes are assigned in join order
function stubs.add_player(name, index, is_connected) function stubs.add_player(name, is_connected)
local index = #players_by_index + 1
local player = Stubs.strict("LuaPlayer " .. name, { local player = Stubs.strict("LuaPlayer " .. name, {
name = name, name = name,
index = index, index = index,