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>
This commit is contained in:
bbassie
2026-08-22 22:21:39 +00:00
co-authored by Claude Fable 5
parent 24d36db601
commit 7adcdde9dc
13 changed files with 592 additions and 448 deletions
+121 -118
View File
@@ -65,131 +65,134 @@ async function startPlugin(t2, { roles = [], users = [], config = {} } = {}) {
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")] });
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");
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],
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");
});
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",
);
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"));
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")],
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");
});
await plugin.handleSeedRolesRequest();
t2.strictSame(controller.roles.size, seedRoles.length, "every seed role exists");
t2.test("rolesUpdated and roleMetaUpdated broadcast to subscribers", async t2 => {
const { plugin, controller, state } = await startPlugin(t2, { roles: [role(1, "Player")] });
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");
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");
await plugin.handleSeedRolesRequest();
t2.strictSame(controller.roles.size, seedRoles.length, "seeding again reuses the roles");
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();
});