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 <noreply@anthropic.com>
This commit is contained in:
bbassie
2026-08-22 16:45:51 +00:00
co-authored by Claude Fable 5
parent 59a0e0be13
commit 24d36db601
14 changed files with 1106 additions and 434 deletions
+195
View File
@@ -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");
});
+143
View File
@@ -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");
});
+107 -81
View File
@@ -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 = "<server>", silent = true })
check(eq(env.names(Roles.get_player_roles(alice)), { "Jail" }), "jail suppresses every other role")
check(R("Moderator"):has_player(alice), "a suppressed role is still held")
check(not Roles.player_has_permission(alice, "exp_scenario.command.kill"), "a jailed player loses permissions")
check(not Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "a jailed player loses the default role")
check(not Roles.player_outranks(alice, bob), "a jailed player no longer outranks")
check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0,
"the jail event lists only the jail role")
env.reset_log()
R("Jail"):unassign(alice, { by_player_name = "<server>", silent = true })
check(eq(env.sorted(env.names(Roles.get_player_roles(alice))), { "Moderator", "Player" }), "unjail restores the roles")
check(#env.events[1].data.assigned == 0 and eq(env.events[1].data.unassigned, { 7 }),
"the unjail event lists only the jail role")
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 = "<server>", 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 = "<server>", 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()
+21 -130
View File
@@ -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 = "<server>" }, {
__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
+71 -42
View File
@@ -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()
+39 -27
View File
@@ -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()
+71 -46
View File
@@ -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)), { "<server>" }), "nil is the server")
check(eq(names(Roles.get_player_roles(env.server)), { "<server>" }), "a player with index 0 is the server")
check(Roles.get_player_highest_role(alice) == R("Moderator"), "highest role")
check(Roles.get_player_highest_role(carol) == R("Player"), "highest role is the default role when none are held")
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)), { "<server>" }), "nil is the server")
check(eq(Test.names(env.Roles.get_player_roles(env.server)), { "<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()
+101 -74
View File
@@ -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] == "<server>", "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] == "<server>", "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()
+64 -12
View File
@@ -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();
});
+6 -3
View File
@@ -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)));
}
+41
View File
@@ -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,
};
+109
View File
@@ -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
@@ -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 } : {});
}
+118
View File
@@ -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 = "<server>" })
--- 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