Files
factorio-scenario-ExpCluster/exp_roles/test/controller.test.js
T
bbassieandClaude Fable 5 3218351db6 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>
2026-08-25 21:11:58 +00:00

220 lines
10 KiB
JavaScript

"use strict";
const t = require("tap");
const lib = require("@clusterio/lib");
const { Controller } = require("@clusterio/controller");
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");
// The controller validates message classes against the link registry
lib.Link.register(messages.RoleUpdatedEvent);
lib.Link.register(messages.AssignmentUpdatedEvent);
lib.Link.register(messages.RoleListRequest);
lib.Link.register(messages.RoleMetaUpdateRequest);
lib.Link.register(messages.SeedRolesRequest);
lib.Link.register(messages.AssignmentListRequest);
lib.Link.register(messages.AssignmentUpdateRequest);
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
/** Build a plugin around a real controller, which is side effect free while not started. */
async function startPlugin(t2, { roles = [] } = {}) {
const controllerConfig = new lib.ControllerConfig("controller", {
"controller.database_directory": t2.testdir(),
"controller.default_role_id": lib.Role.DefaultPlayerRoleId,
});
const controller = new Controller(logger, [], controllerConfig);
for (const role of roles) {
controller.roles.set(role);
}
// Spies which record and then defer to the real behaviour
const state = { broadcasts: [], permissionUpdates: [] };
const broadcast = controller.subscriptions.broadcast.bind(controller.subscriptions);
controller.subscriptions.broadcast = event => {
state.broadcasts.push(event);
broadcast(event);
};
const permissionsUpdated = controller.userPermissionsUpdated.bind(controller);
controller.userPermissionsUpdated = user => {
state.permissionUpdates.push(user.name);
permissionsUpdated(user);
};
const plugin = new ControllerPlugin({ name: "exp_roles" }, controller, undefined, logger);
await plugin.init();
return { plugin, controller, state };
}
const role = (id, name, permissions = []) => new lib.Role(id, name, "", new Set(permissions));
t.test("class ControllerPlugin", t2 => {
t2.test(".init() and .sweepRoleMeta() manage the role properties", async t3 => {
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(5).order, 2, "the next role is ordered after it");
plugin.roleMeta.set(new messages.RoleMetaRecord(99, 3));
plugin.sweepRoleMeta();
t3.notOk(plugin.roleMeta.get(99), "properties without a role are removed");
t3.ok(plugin.roleMeta.get(5), "properties with a role are kept");
});
t2.test(".buildRoleRecord() combines the role with its properties", async t3 => {
const { plugin, controller } = await startPlugin(t3, {
roles: [role(1, "Player"), role(5, "Moderator", ["exp_scenario.command.kill"])],
});
plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 1, "Mod"));
const record = plugin.buildRoleRecord(controller.roles.get(5));
t3.strictSame(record.name, "Moderator", "the name comes from the role");
t3.strictSame(record.permissions, ["exp_scenario.command.kill"], "the permissions come from the role");
t3.strictSame(record.meta.shortHand, "Mod", "the properties come from the datastore");
t3.notOk(record.isDefault, "not the default role");
t3.ok(plugin.buildRoleRecord(controller.roles.get(1)).isDefault, "the default role is marked");
});
t2.test(".rolesUpdated() and .roleMetaUpdated() broadcast to subscribers", async t3 => {
const { plugin, controller, state } = await startPlugin(t3, { roles: [role(1, "Player")] });
state.broadcasts.length = 0;
controller.roles.set(role(5, "Moderator"));
const updated = state.broadcasts
.filter(event => event instanceof messages.RoleUpdatedEvent)
.flatMap(event => event.updates.map(update => update.name));
t3.ok(updated.includes("Moderator"), "a new role is broadcast");
state.broadcasts.length = 0;
plugin.roleMeta.set(new messages.RoleMetaRecord(5, 2, 0, "Mod"));
t3.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");
t3.ok(
state.broadcasts.some(event => event instanceof messages.RoleUpdatedEvent),
"a default role change rebroadcasts",
);
});
t2.test(".handleRoleSubscription() replays only newer records", async t3 => {
const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player")] });
controller.roles.set(role(5, "Moderator"));
const updatedAtMs = plugin.buildRoleRecord(controller.roles.get(5)).updatedAtMs;
const all = await plugin.handleRoleSubscription({ lastRequestTimeMs: 0 });
t3.strictSame(all.updates.length, 2, "everything is replayed from the start");
const none = await plugin.handleRoleSubscription({ lastRequestTimeMs: updatedAtMs });
t3.strictSame(none, null, "nothing is replayed when up to date");
});
t2.test(".listAssignmentRecords() mirrors users without the default role", async t3 => {
const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] });
controller.users.createUser("alice").set("roleIds", new Set([lib.Role.DefaultPlayerRoleId, 5]));
controller.users.createUser("bob");
const records = plugin.listAssignmentRecords();
t3.strictSame(records.length, 1, "users with only the default role are left out");
t3.strictSame(records[0].name, "alice");
t3.strictSame([...records[0].roleIds], [5], "the default role is not listed");
});
t2.test(".handleAssignmentUpdateRequest() validates the user and roles", async t3 => {
const { plugin, controller, state } = await startPlugin(t3, {
roles: [role(1, "Player"), role(5, "Moderator"), role(6, "Regular")],
});
controller.users.createUser("alice").set("roleIds", new Set([5]));
await t3.rejects(
plugin.handleAssignmentUpdateRequest(new messages.AssignmentUpdateRequest("nobody", [], [])),
{ message: /does not exist/ }, "an unknown user is refused",
);
await t3.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]));
t3.strictSame([...controller.users.getByName("alice").roleIds], [6], "roles are added and removed");
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 => {
const { plugin, controller } = await startPlugin(t3, {
roles: [role(1, "Player"), role(6, "Regular"), role(7, "Jail")],
});
const onlineTime = ms => new Map([[1, lib.PlayerStats.fromJSON({ online_time_ms: ms })]]);
controller.users.createUser("newcomer").set("instanceStats", onlineTime(3600000));
controller.users.createUser("veteran").set("instanceStats", onlineTime(7200000));
const jailed = controller.users.createUser("jailed");
jailed.set("roleIds", new Set([7]));
jailed.set("instanceStats", onlineTime(7200000));
const roleIds = name => controller.users.getByName(name).roleIds;
// 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));
t3.ok(roleIds("veteran").has(6), "enough online time grants the role");
t3.notOk(roleIds("newcomer").has(6), "not enough online time does not");
t3.notOk(roleIds("jailed").has(6), "a blocking role prevents the grant");
// Any user update also applies auto assignment, so the leave path is
// observed directly rather than through a role change
const applied = [];
plugin.applyAutoAssign = names => applied.push(names);
await plugin.onPlayerEvent(undefined, { type: "leave", name: "veteran" });
t3.strictSame(applied, [["veteran"]], "a leave applies auto assignment for that user");
await plugin.onPlayerEvent(undefined, { type: "join", name: "veteran" });
t3.strictSame(applied.length, 1, "other player events do not");
});
t2.test(".handleRoleMetaUpdateRequest() validates the role", async t3 => {
const { plugin } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] });
await t3.rejects(
plugin.handleRoleMetaUpdateRequest(new messages.RoleMetaUpdateRequest(new messages.RoleMetaRecord(99, 1))),
{ message: /does not exist/ }, "properties for an unknown role are refused",
);
await plugin.handleRoleMetaUpdateRequest(new messages.RoleMetaUpdateRequest(
new messages.RoleMetaRecord(5, 2, 0, "Mod"),
));
t3.strictSame(plugin.roleMeta.get(5).shortHand, "Mod", "the properties are stored");
});
t2.test(".handleAssignmentSubscription() replays only newer records", async t3 => {
const { plugin, controller } = await startPlugin(t3, { roles: [role(1, "Player"), role(5, "Moderator")] });
controller.users.createUser("alice").set("roleIds", new Set([5]));
const updatedAtMs = plugin.listAssignmentRecords()[0].updatedAtMs;
const all = await plugin.handleAssignmentSubscription({ lastRequestTimeMs: 0 });
t3.strictSame(all.updates.length, 1, "everything is replayed from the start");
const none = await plugin.handleAssignmentSubscription({ lastRequestTimeMs: updatedAtMs });
t3.strictSame(none, null, "nothing is replayed when up to date");
});
t2.test(".handleSeedRolesRequest() creates the roles and reuses them by name", async t3 => {
const { plugin, controller } = await startPlugin(t3, {
roles: [role(0, "Cluster Admin", ["core.admin"]), role(1, "Player")],
});
await plugin.handleSeedRolesRequest();
t3.strictSame(controller.roles.size, seedRoles.length, "every seed role exists");
const moderator = [...controller.roles.values()].find(other => other.name === "Moderator");
t3.ok(moderator.permissions.has("exp_scenario.command.jail"), "parent permissions are flattened in");
t3.ok(plugin.roleMeta.get(moderator.id), "the role properties are created");
t3.strictSame(plugin.roleMeta.get(moderator.id).shortHand, "Mod", "the properties match the seed");
await plugin.handleSeedRolesRequest();
t3.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles");
});
t2.end();
});