Files
factorio-scenario-ExpCluster/exp_roles/test/instance.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

147 lines
7.0 KiB
JavaScript

"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("class InstancePlugin", t2 => {
t2.test("onStart 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");
});
t2.test("onStart with 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]);
});
t2.test("onStart with disabled does nothing", 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");
});
t2.test("onStart warns when no default role is set", 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");
});
t2.test("handleRoleUpdatedEvent and handleAssignmentUpdatedEvent forward 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");
});
t2.test("handleRoleUpdatedEvent and handleAssignmentUpdatedEvent are gated 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");
});
t2.test("handleAssignmentUpdateIPC sends the change and rolls back a refusal", 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");
});
t2.test("handleAssignmentUpdateIPC is gated 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");
});
t2.test("onInstanceConfigFieldChanged toggles emit with the sync mode", 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");
});
t2.end();
});