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");
});