Files
factorio-scenario-ExpCluster/exp_roles/test/controller.test.js
T
bbassieandClaude Fable 5 7adcdde9dc Address review on the test framework
- The registry is a Suite, created around the environment factory it runs its
  tests with, so the plugin's env.lua reads as: build environments, hand the
  test files a suite of them. The suite returned there becomes `...` in each
  test file, which is now said where it happens.
- pass and fail are the primitives every other check goes through. eq and
  deep_eq assert rather than compare, failing with both values in the detail.
- Stubs are extended through extend_requires, extend_script, extend_game and
  extend_defines, a recursive merge which raises when a value already exists,
  rather than by mutating the tables. The strict labels carry the factorio
  class names.
- The stubs record registered metatables and can save and load the script
  data the way factorio does: functions are refused and only registered
  metatables survive. env.save_load() uses it, and a new on_load test shows
  role methods and held roles surviving the round trip, which only passes
  because the module registers its metatable.
- The names helper moved out of the shared framework, it is role specific.
- Tests are named after the function or method they cover, on both sides:
  the lua tests read as "role:assign applies locally and is sent", and the
  javascript files wrap their tests in the class they exercise with subtests
  per method. module.test.js is control.test.js, after the file it covers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 22:21:39 +00:00

199 lines
8.6 KiB
JavaScript

"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("class ControllerPlugin", t2 => {
t2.test("init and sweepRoleMeta manage the role properties", 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");
});
t2.test("buildRoleRecord combines 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");
});
t2.test("rolesUpdated and roleMetaUpdated 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");
});
t2.test("handleRoleSubscription replays 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");
});
t2.test("listAssignmentRecords mirrors 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");
});
t2.test("handleAssignmentUpdateRequest validates 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");
});
t2.test("applyAutoAssign and onPlayerEvent grant roles 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");
});
t2.test("handleSeedRolesRequest 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");
});
t2.end();
});