mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-09-21 17:04:00 +00:00
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:
co-authored by
Claude Fable 5
parent
59a0e0be13
commit
24d36db601
@@ -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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"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("start 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test("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]);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test("disabled does nothing on start", 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test("a missing default role is warned about", 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test("updates from the controller are forwarded 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test("updates are not forwarded 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test("in game changes are sent up and rolled back when refused", 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test("in game changes are dropped 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test("changing the sync mode toggles emit", 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");
|
||||||
|
});
|
||||||
@@ -1,93 +1,119 @@
|
|||||||
--- Assignment through role methods, confirmation, and rejection
|
--- Assignment through role methods, confirmation, and rejection
|
||||||
local Env = ...
|
local Env = ...
|
||||||
local env = Env.new()
|
local Test = Env.Test
|
||||||
local check, eq, R = env.check, env.eq, env.R
|
local test, check, eq = Test.test, Test.check, Test.eq
|
||||||
local Roles = env.Roles
|
|
||||||
local sd = Roles._script_data()
|
|
||||||
|
|
||||||
local alice = env.add_player("alice", 1)
|
--- alice is a moderator and bob a regular, with changes sent to the controller
|
||||||
local bob = env.add_player("bob", 2)
|
local function setup(env)
|
||||||
env.initialise{
|
local players = {
|
||||||
Env.assignment("alice", { 5 }),
|
alice = env.add_player("alice", 1),
|
||||||
Env.assignment("bob", { 6 }),
|
bob = env.add_player("bob", 2),
|
||||||
}
|
}
|
||||||
Roles.set_emit_events(true)
|
env.initialise{
|
||||||
env.reset_log()
|
Env.assignment("alice", { 5 }),
|
||||||
|
Env.assignment("bob", { 6 }),
|
||||||
|
}
|
||||||
|
env.Roles.set_emit_events(true)
|
||||||
|
env.reset_log()
|
||||||
|
return players
|
||||||
|
end
|
||||||
|
|
||||||
-- Assign with sync
|
test("assign applies locally and is sent to the controller", function(env)
|
||||||
R("Moderator"):assign(bob, { by_player_name = "alice" })
|
local players = setup(env)
|
||||||
check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "assignment is sent to the controller")
|
env.R("Moderator"):assign(players.bob, { by_player_name = "alice" })
|
||||||
check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "assignment payload")
|
check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "the assignment is sent")
|
||||||
check(R("Moderator"):has_player(bob), "the role applies before confirmation")
|
check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "the payload names the role and player")
|
||||||
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "event carries the assigned role id")
|
check(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation")
|
||||||
check(env.events[1].data.by_player_index == 1, "event carries who made the change")
|
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event carries the assigned role id")
|
||||||
check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced")
|
check(env.events[1].data.by_player_index == 1, "the event carries who made the change")
|
||||||
check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "assign sound")
|
check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced")
|
||||||
check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "held locally and pending")
|
check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "the assign sound plays")
|
||||||
|
|
||||||
env.reset_log()
|
local sd = env.Roles._script_data()
|
||||||
R("Moderator"):assign(bob)
|
check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "the role is held locally and pending")
|
||||||
check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op")
|
|
||||||
|
|
||||||
-- Controller confirms
|
env.reset_log()
|
||||||
env.reset_log()
|
env.R("Moderator"):assign(players.bob)
|
||||||
Roles.receive_assignment_updates{ Env.assignment("bob", { 6, 5 }) }
|
check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op")
|
||||||
check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing")
|
end)
|
||||||
check(sd.local_players.bob == nil and sd.pending.bob == nil, "a confirmed role is no longer held locally")
|
|
||||||
check(R("Moderator"):has_player(bob), "the role is still held after confirmation")
|
|
||||||
|
|
||||||
-- Unassign a synced role
|
test("a confirmation releases the local hold", function(env)
|
||||||
env.reset_log()
|
local players = setup(env)
|
||||||
R("Moderator"):unassign(bob, { by_player_name = "alice", silent = true })
|
env.R("Moderator"):assign(players.bob)
|
||||||
check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 5 }), "unassignment is sent to the controller")
|
env.reset_log()
|
||||||
check(not R("Moderator"):has_player(bob), "the role is removed locally")
|
env.Roles.receive_assignment_updates{ Env.assignment("bob", { 6, 5 }) }
|
||||||
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "event carries the unassigned role id")
|
check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing")
|
||||||
check(#env.printed == 0, "silent suppresses the announcement")
|
|
||||||
check(#env.sounds == 1 and env.sounds[1] == "bob:utility/game_lost", "unassign sound")
|
|
||||||
Roles.receive_assignment_updates{ Env.assignment("bob", { 6 }) }
|
|
||||||
|
|
||||||
-- Rejection rolls back
|
local sd = env.Roles._script_data()
|
||||||
env.reset_log()
|
check(sd.local_players.bob == nil and sd.pending.bob == nil, "the role is no longer held locally")
|
||||||
R("Jail"):assign(alice)
|
check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation")
|
||||||
check(R("Jail"):has_player(alice), "the role applies before rejection")
|
end)
|
||||||
env.reset_log()
|
|
||||||
Roles.reject_assignment{ name = "alice", role_ids = { 7 } }
|
|
||||||
check(not R("Jail"):has_player(alice), "a rejected role is rolled back")
|
|
||||||
check(#env.sent == 0, "the rollback is not sent to the controller")
|
|
||||||
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 7 }), "the rollback raises the event")
|
|
||||||
check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state")
|
|
||||||
|
|
||||||
-- Local only roles
|
test("unassign a synced role", function(env)
|
||||||
env.reset_log()
|
local players = setup(env)
|
||||||
R("Regular"):assign(alice, { silent = true, local_only = true })
|
env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true })
|
||||||
check(#env.sent == 0, "a local only role is not sent")
|
check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 6 }), "the unassignment is sent")
|
||||||
check(R("Regular"):has_player(alice), "a local only role applies")
|
check(not env.R("Regular"):has_player(players.bob), "the role is removed locally")
|
||||||
check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending")
|
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 6 }), "the event carries the unassigned role id")
|
||||||
Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) }
|
check(#env.printed == 0, "silent suppresses the announcement")
|
||||||
check(R("Regular"):has_player(alice), "a local only role survives controller updates")
|
check(#env.sounds == 1 and env.sounds[1] == "bob:utility/game_lost", "the unassign sound plays")
|
||||||
env.reset_log()
|
end)
|
||||||
R("Regular"):unassign(alice, { local_only = true })
|
|
||||||
check(not R("Regular"):has_player(alice) and #env.sent == 0, "a local only role is removed without sync")
|
|
||||||
|
|
||||||
-- Jail priority
|
test("a rejection rolls the assignment back", function(env)
|
||||||
env.reset_log()
|
local players = setup(env)
|
||||||
R("Jail"):assign(alice, { by_player_name = "<server>", silent = true })
|
env.R("Jail"):assign(players.alice)
|
||||||
check(eq(env.names(Roles.get_player_roles(alice)), { "Jail" }), "jail suppresses every other role")
|
check(env.R("Jail"):has_player(players.alice), "the role applies before rejection")
|
||||||
check(R("Moderator"):has_player(alice), "a suppressed role is still held")
|
|
||||||
check(not Roles.player_has_permission(alice, "exp_scenario.command.kill"), "a jailed player loses permissions")
|
|
||||||
check(not Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "a jailed player loses the default role")
|
|
||||||
check(not Roles.player_outranks(alice, bob), "a jailed player no longer outranks")
|
|
||||||
check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0,
|
|
||||||
"the jail event lists only the jail role")
|
|
||||||
env.reset_log()
|
|
||||||
R("Jail"):unassign(alice, { by_player_name = "<server>", silent = true })
|
|
||||||
check(eq(env.sorted(env.names(Roles.get_player_roles(alice))), { "Moderator", "Player" }), "unjail restores the roles")
|
|
||||||
check(#env.events[1].data.assigned == 0 and eq(env.events[1].data.unassigned, { 7 }),
|
|
||||||
"the unjail event lists only the jail role")
|
|
||||||
|
|
||||||
-- The server can not be assigned roles
|
env.reset_log()
|
||||||
env.reset_log()
|
env.Roles.reject_assignment{ name = "alice", role_ids = { 7 } }
|
||||||
R("Moderator"):assign(env.server)
|
check(not env.R("Jail"):has_player(players.alice), "the rejected role is rolled back")
|
||||||
check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op")
|
check(#env.sent == 0, "the rollback is not sent to the controller")
|
||||||
|
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 7 }), "the rollback raises the event")
|
||||||
|
|
||||||
return Env.results_json(env)
|
local sd = env.Roles._script_data()
|
||||||
|
check(sd.local_players.alice == nil and sd.pending.alice == nil, "the rollback clears the local state")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("local only roles never reach the controller", function(env)
|
||||||
|
local players = setup(env)
|
||||||
|
env.R("Regular"):assign(players.alice, { silent = true, local_only = true })
|
||||||
|
check(#env.sent == 0, "a local only role is not sent")
|
||||||
|
check(env.R("Regular"):has_player(players.alice), "a local only role applies")
|
||||||
|
|
||||||
|
local sd = env.Roles._script_data()
|
||||||
|
check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending")
|
||||||
|
|
||||||
|
env.Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) }
|
||||||
|
check(env.R("Regular"):has_player(players.alice), "a local only role survives controller updates")
|
||||||
|
|
||||||
|
env.reset_log()
|
||||||
|
env.R("Regular"):unassign(players.alice, { local_only = true })
|
||||||
|
check(not env.R("Regular"):has_player(players.alice) and #env.sent == 0, "removed without sync")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("jail suppresses every other role", function(env)
|
||||||
|
local players = setup(env)
|
||||||
|
env.R("Jail"):assign(players.alice, { by_player_name = "<server>", silent = true })
|
||||||
|
check(eq(Test.names(env.Roles.get_player_roles(players.alice)), { "Jail" }), "only the jail role applies")
|
||||||
|
check(env.R("Moderator"):has_player(players.alice), "a suppressed role is still held")
|
||||||
|
check(not env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "permissions are lost")
|
||||||
|
check(not env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "the default role is lost")
|
||||||
|
check(not env.Roles.player_outranks(players.alice, players.bob), "a jailed player no longer outranks")
|
||||||
|
check(eq(env.events[1].data.assigned, { 7 }) and #env.events[1].data.unassigned == 0,
|
||||||
|
"the event lists only the jail role")
|
||||||
|
|
||||||
|
env.reset_log()
|
||||||
|
env.R("Jail"):unassign(players.alice, { by_player_name = "<server>", silent = true })
|
||||||
|
check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }),
|
||||||
|
"unjail restores the roles")
|
||||||
|
check(#env.events[1].data.assigned == 0 and eq(env.events[1].data.unassigned, { 7 }),
|
||||||
|
"the unjail event lists only the jail role")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("the server can not be assigned roles", function(env)
|
||||||
|
setup(env)
|
||||||
|
env.R("Moderator"):assign(env.server)
|
||||||
|
check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return Env.finish()
|
||||||
|
|||||||
+21
-130
@@ -1,14 +1,23 @@
|
|||||||
--[[-- Test environment for module/control.lua
|
--[[-- Test environment for module/control.lua
|
||||||
Stubs the factorio globals and the clusterio modules, then loads a fresh copy
|
Composes the shared stubs and framework from the repository test folder with
|
||||||
of the roles module. Each call to Env.new() is independent.
|
the fixtures for this plugin. Each call to Env.new() is an independent
|
||||||
|
environment with a fresh copy of the roles module.
|
||||||
|
|
||||||
Run as a chunk by test/helpers/lua.js, receiving the module directory and
|
Run as a chunk by test/lua/runner.js, receiving the shared folder and
|
||||||
returning this table, which is in turn passed to each test chunk.
|
returning this table, which is in turn passed to each test chunk.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local module_root = ... --- @type string
|
local shared_root = ... --- @type string
|
||||||
|
local source = assert(debug.getinfo(1, "S")).source
|
||||||
|
local plugin_root = assert(source:match("^@(.*)/test/lua/env%.lua$"))
|
||||||
|
|
||||||
local Env = {}
|
local Stubs = assert(loadfile(shared_root .. "/stubs.lua"))()
|
||||||
|
local Framework = assert(loadfile(shared_root .. "/framework.lua"))()
|
||||||
|
|
||||||
|
local Env = {
|
||||||
|
--- Test registry for the file being run, see framework.lua
|
||||||
|
Test = Framework.new(),
|
||||||
|
}
|
||||||
|
|
||||||
--- Standard fixture: permission names sent once and referenced by zero based index
|
--- Standard fixture: permission names sent once and referenced by zero based index
|
||||||
Env.permission_names = {
|
Env.permission_names = {
|
||||||
@@ -44,83 +53,12 @@ end
|
|||||||
|
|
||||||
--- Create an independent environment with a fresh copy of the roles module
|
--- Create an independent environment with a fresh copy of the roles module
|
||||||
function Env.new()
|
function Env.new()
|
||||||
local env = {
|
local env = Stubs.new()
|
||||||
events = {}, -- events raised through script.raise_event
|
|
||||||
printed = {}, -- game.print and player.print messages
|
|
||||||
sent = {}, -- payloads sent to the controller
|
|
||||||
sounds = {}, -- sounds played to players
|
|
||||||
results = {}, -- test results collected by env.check
|
|
||||||
}
|
|
||||||
|
|
||||||
local next_event_id = 100
|
env.Roles = assert(loadfile(plugin_root .. "/module/control.lua"))()
|
||||||
script = {
|
|
||||||
generate_event_name = function()
|
|
||||||
next_event_id = next_event_id + 1
|
|
||||||
return next_event_id
|
|
||||||
end,
|
|
||||||
raise_event = function(id, data)
|
|
||||||
env.events[#env.events + 1] = { id = id, data = data }
|
|
||||||
end,
|
|
||||||
register_metatable = function() end,
|
|
||||||
}
|
|
||||||
defines = { events = { on_player_joined_game = 1, on_multiplayer_init = 2 } }
|
|
||||||
|
|
||||||
local players_by_name, players_by_index, connected = {}, {}, {}
|
|
||||||
game = {
|
|
||||||
tick = 1,
|
|
||||||
player = nil,
|
|
||||||
players = players_by_name,
|
|
||||||
connected_players = connected,
|
|
||||||
print = function(message) env.printed[#env.printed + 1] = message end,
|
|
||||||
get_player = function(key) return players_by_name[key] or players_by_index[key] end,
|
|
||||||
}
|
|
||||||
|
|
||||||
local stubs = {
|
|
||||||
["modules/clusterio/api"] = {
|
|
||||||
send_json = function(channel, data)
|
|
||||||
env.sent[#env.sent + 1] = { channel = channel, data = data }
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
["modules/clusterio/compat"] = { script_data = {} },
|
|
||||||
["modules/exp_util/async"] = {
|
|
||||||
register = function(callback)
|
|
||||||
return function(...) return callback(...) end
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
require = function(name)
|
|
||||||
return assert(stubs[name], "Unexpected require: " .. name)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Add a player to the stubbed game
|
|
||||||
function env.add_player(name, index, is_connected)
|
|
||||||
local player = {
|
|
||||||
name = name,
|
|
||||||
index = index,
|
|
||||||
connected = is_connected ~= false,
|
|
||||||
valid = true,
|
|
||||||
play_sound = function(opts)
|
|
||||||
env.sounds[#env.sounds + 1] = name .. ":" .. opts.path
|
|
||||||
end,
|
|
||||||
print = function(message)
|
|
||||||
env.printed[#env.printed + 1] = { to = name, message }
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
players_by_name[name] = player
|
|
||||||
players_by_index[index] = player
|
|
||||||
if player.connected then connected[#connected + 1] = player end
|
|
||||||
return player
|
|
||||||
end
|
|
||||||
|
|
||||||
--- A player object which represents the server
|
|
||||||
env.server = setmetatable({ index = 0, name = "<server>" }, {
|
|
||||||
__index = function(_, key) error("Server player field accessed: " .. tostring(key)) end,
|
|
||||||
})
|
|
||||||
|
|
||||||
env.Roles = assert(loadfile(module_root .. "/control.lua"))()
|
|
||||||
env.Roles.on_server_startup()
|
env.Roles.on_server_startup()
|
||||||
|
|
||||||
--- Get a role by name, failing the test file when it does not exist
|
--- Get a role by name, failing the test when it does not exist
|
||||||
function env.R(name)
|
function env.R(name)
|
||||||
return (assert(env.Roles.get_role_by_name(name), "No role named " .. name))
|
return (assert(env.Roles.get_role_by_name(name), "No role named " .. name))
|
||||||
end
|
end
|
||||||
@@ -134,60 +72,13 @@ function Env.new()
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Forget everything recorded so far
|
|
||||||
function env.reset_log()
|
|
||||||
env.events, env.printed, env.sent, env.sounds = {}, {}, {}, {}
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Record one test result
|
|
||||||
function env.check(ok, name, detail)
|
|
||||||
env.results[#env.results + 1] = { name = name, ok = not not ok, detail = detail }
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Shallow array equality
|
|
||||||
function env.eq(a, b)
|
|
||||||
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
|
|
||||||
if #a ~= #b then return false end
|
|
||||||
for i = 1, #a do
|
|
||||||
if a[i] ~= b[i] then return false end
|
|
||||||
end
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Names of an array of roles
|
|
||||||
function env.names(roles)
|
|
||||||
local rtn = {}
|
|
||||||
for index, role in ipairs(roles) do
|
|
||||||
rtn[index] = role.name
|
|
||||||
end
|
|
||||||
return rtn
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Sorted copy of an array of strings
|
|
||||||
function env.sorted(list)
|
|
||||||
local rtn = {}
|
|
||||||
for index, value in ipairs(list) do rtn[index] = value end
|
|
||||||
table.sort(rtn)
|
|
||||||
return rtn
|
|
||||||
end
|
|
||||||
|
|
||||||
return env
|
return env
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Encode the results of an environment as JSON for the javascript side
|
--- Run the declared tests, each against a fresh environment
|
||||||
function Env.results_json(env)
|
function Env.finish()
|
||||||
local function escape(value)
|
Env.Test.run(Env.new)
|
||||||
return (value:gsub('[%c"\\]', function(c)
|
return Env.Test.results_json()
|
||||||
return string.format("\\u%04x", c:byte())
|
|
||||||
end))
|
|
||||||
end
|
|
||||||
|
|
||||||
local parts = {}
|
|
||||||
for index, result in ipairs(env.results) do
|
|
||||||
local detail = result.detail and string.format(',"detail":"%s"', escape(result.detail)) or ""
|
|
||||||
parts[index] = string.format('{"name":"%s","ok":%s%s}', escape(result.name), tostring(result.ok), detail)
|
|
||||||
end
|
|
||||||
return "[" .. table.concat(parts, ",") .. "]"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return Env
|
return Env
|
||||||
|
|||||||
@@ -1,47 +1,76 @@
|
|||||||
--- Listing and printing to the players who hold a role
|
--- Listing and printing to the players who hold a role
|
||||||
local Env = ...
|
local Env = ...
|
||||||
local env = Env.new()
|
local Test = Env.Test
|
||||||
local check, eq, R = env.check, env.eq, env.R
|
local test, check, eq = Test.test, Test.check, Test.eq
|
||||||
local Roles = env.Roles
|
|
||||||
|
|
||||||
local alice = env.add_player("alice", 1)
|
--- alice and dave are moderators with dave offline, zed has never joined
|
||||||
env.add_player("bob", 2)
|
local function setup(env)
|
||||||
env.add_player("dave", 4, false) -- offline
|
local players = {
|
||||||
env.initialise{
|
alice = env.add_player("alice", 1),
|
||||||
Env.assignment("alice", { 5 }),
|
bob = env.add_player("bob", 2),
|
||||||
Env.assignment("bob", { 6 }),
|
dave = env.add_player("dave", 4, false),
|
||||||
Env.assignment("dave", { 5 }),
|
}
|
||||||
Env.assignment("zed", { 5 }), -- has never joined this map
|
env.initialise{
|
||||||
}
|
Env.assignment("alice", { 5 }),
|
||||||
Roles.set_emit_events(true)
|
Env.assignment("bob", { 6 }),
|
||||||
|
Env.assignment("dave", { 5 }),
|
||||||
local mod = R("Moderator")
|
Env.assignment("zed", { 5 }),
|
||||||
check(eq(env.sorted(mod:get_player_names()), { "alice", "dave", "zed" }),
|
}
|
||||||
"player names include players not on this map")
|
env.Roles.set_emit_events(true)
|
||||||
check(eq(env.sorted(env.names(mod:get_players())), { "alice", "dave" }),
|
return players
|
||||||
"players are limited to this map")
|
|
||||||
check(eq(env.names(mod:get_players(true)), { "alice" }), "players can be filtered by connected state")
|
|
||||||
check(eq(env.names(mod:get_players(false)), { "dave" }), "players can be filtered to offline")
|
|
||||||
|
|
||||||
-- A player holding the role in both the synced and the local list counts once
|
|
||||||
R("Regular"):assign(alice, { silent = true, local_only = true })
|
|
||||||
Roles.receive_assignment_updates{ Env.assignment("alice", { 5, 6 }) }
|
|
||||||
local sd = Roles._script_data()
|
|
||||||
check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists")
|
|
||||||
check(eq(env.sorted(R("Regular"):get_player_names()), { "alice", "bob" }),
|
|
||||||
"a player in both lists is counted once")
|
|
||||||
|
|
||||||
env.reset_log()
|
|
||||||
check(mod:print("hello") == 1, "print returns the number of players reached")
|
|
||||||
check(#env.printed == 1 and env.printed[1].to == "alice", "print reaches only online holders")
|
|
||||||
|
|
||||||
env.reset_log()
|
|
||||||
for _, role in ipairs(Roles.get_higher_roles(R("Regular"))) do
|
|
||||||
role:print("hello")
|
|
||||||
end
|
end
|
||||||
local got = {}
|
|
||||||
for _, entry in ipairs(env.printed) do got[#got + 1] = entry.to end
|
|
||||||
-- alice holds two of the roles, so like the legacy system the message can repeat
|
|
||||||
check(eq(env.sorted(got), { "alice", "alice", "bob" }), "printing to the higher roles reaches their online holders")
|
|
||||||
|
|
||||||
return Env.results_json(env)
|
test("player names include players not on this map", function(env)
|
||||||
|
setup(env)
|
||||||
|
check(eq(Test.sorted(env.R("Moderator"):get_player_names()), { "alice", "dave", "zed" }),
|
||||||
|
"every player given the role is listed")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("players are limited to this map and can be filtered", function(env)
|
||||||
|
setup(env)
|
||||||
|
local mod = env.R("Moderator")
|
||||||
|
check(eq(Test.sorted(Test.names(mod:get_players())), { "alice", "dave" }), "players are limited to this map")
|
||||||
|
check(eq(Test.names(mod:get_players(true)), { "alice" }), "filtered to connected players")
|
||||||
|
check(eq(Test.names(mod:get_players(false)), { "dave" }), "filtered to offline players")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("a player holding the role in both lists is counted once", function(env)
|
||||||
|
local players = setup(env)
|
||||||
|
env.R("Regular"):assign(players.alice, { silent = true, local_only = true })
|
||||||
|
env.Roles.receive_assignment_updates{ Env.assignment("alice", { 5, 6 }) }
|
||||||
|
|
||||||
|
local sd = env.Roles._script_data()
|
||||||
|
check(sd.local_players.alice ~= nil and sd.synced_players.alice ~= nil, "the role is held in both lists")
|
||||||
|
check(eq(Test.sorted(env.R("Regular"):get_player_names()), { "alice", "bob" }), "the player is counted once")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("print reaches online holders", function(env)
|
||||||
|
setup(env)
|
||||||
|
env.reset_log()
|
||||||
|
check(env.R("Moderator"):print("hello") == 1, "print returns the number of players reached")
|
||||||
|
check(#env.printed == 1 and env.printed[1].to == "alice", "only online holders are reached")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("printing to the higher roles", function(env)
|
||||||
|
local players = setup(env)
|
||||||
|
env.R("Regular"):assign(players.alice, { silent = true, local_only = true })
|
||||||
|
env.reset_log()
|
||||||
|
for _, role in ipairs(env.Roles.get_higher_roles(env.R("Regular"))) do
|
||||||
|
role:print("hello")
|
||||||
|
end
|
||||||
|
|
||||||
|
local got = {}
|
||||||
|
for _, entry in ipairs(env.printed) do got[#got + 1] = entry.to end
|
||||||
|
-- alice holds two of the roles, so like the legacy system the message can repeat
|
||||||
|
check(eq(Test.sorted(got), { "alice", "alice", "bob" }), "the online holders of each role are reached")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("deep equality helper", function(env)
|
||||||
|
check(Test.deep_eq({ a = { 1, 2 }, b = "x" }, { a = { 1, 2 }, b = "x" }), "equal nested tables")
|
||||||
|
check(not Test.deep_eq({ a = { 1, 2 } }, { a = { 1, 3 } }), "differing nested values")
|
||||||
|
check(not Test.deep_eq({ a = 1 }, { a = 1, b = 2 }), "extra keys on the right")
|
||||||
|
check(not Test.deep_eq({ a = 1, b = 2 }, { a = 1 }), "extra keys on the left")
|
||||||
|
check(Test.deep_eq(env.Roles._script_data().pending, {}), "works against module state")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return Env.finish()
|
||||||
|
|||||||
@@ -1,36 +1,48 @@
|
|||||||
--- Role lookup, decoding, and comparisons
|
--- Role lookup, decoding, and comparisons
|
||||||
local Env = ...
|
local Env = ...
|
||||||
local env = Env.new()
|
local Test = Env.Test
|
||||||
local check, eq, names, R = env.check, env.eq, env.names, env.R
|
local test, check, eq = Test.test, Test.check, Test.eq
|
||||||
local Roles = env.Roles
|
|
||||||
|
|
||||||
env.initialise{}
|
test("roles are ordered most privileged first", function(env)
|
||||||
|
env.initialise{}
|
||||||
|
check(eq(Test.names(env.Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }),
|
||||||
|
"ordered roles follow their order with deleted roles skipped")
|
||||||
|
check(#env.Roles.get_roles() == 5, "get_roles returns every role")
|
||||||
|
|
||||||
check(eq(names(Roles.get_ordered_roles()), { "Cluster Admin", "Moderator", "Regular", "Jail", "Player" }),
|
local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") }
|
||||||
"ordered roles are most privileged first with deleted roles skipped")
|
check(eq(Test.names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first")
|
||||||
check(#Roles.get_roles() == 5, "get_roles returns every role")
|
end)
|
||||||
check(Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id")
|
|
||||||
check(Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles")
|
|
||||||
check(Roles.get_role(R("Jail").id) == R("Jail"), "lookups return the same object")
|
|
||||||
check(Roles.get_role(9) == nil and Roles.get_role_by_name("Gone") == nil, "deleted roles are not known")
|
|
||||||
check(Roles.get_default_role() == R("Player"), "the default role comes from is_default")
|
|
||||||
|
|
||||||
check(R("Moderator").short_hand == "Mod" or R("Moderator").short_hand == "Moderator",
|
test("roles are looked up by id", function(env)
|
||||||
"short hand falls back to the name")
|
env.initialise{}
|
||||||
check(R("Cluster Admin").short_hand == "SYS", "short hand decoded")
|
check(env.Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id")
|
||||||
check(R("Moderator").color.g == 170, "color decoded")
|
check(env.Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles")
|
||||||
check(R("Jail").priority == 1 and R("Jail").block_auto_assign, "priority and block auto assign decoded")
|
check(env.Roles.get_role(env.R("Jail").id) == env.R("Jail"), "lookups return the same object")
|
||||||
|
check(env.Roles.get_role(9) == nil and env.Roles.get_role_by_name("Gone") == nil, "deleted roles are not known")
|
||||||
|
check(env.Roles.get_default_role() == env.R("Player"), "the default role comes from is_default")
|
||||||
|
end)
|
||||||
|
|
||||||
check(R("Moderator"):is_higher_than(R("Player")), "roles compare on their order")
|
test("role records are decoded", function(env)
|
||||||
check(R("Player"):is_lower_than(R("Moderator")), "is_lower_than is the reverse")
|
env.initialise{}
|
||||||
check(not R("Moderator"):is_higher_than(R("Moderator")), "a role is not higher than itself")
|
check(env.R("Cluster Admin").short_hand == "SYS", "short hand decoded")
|
||||||
|
check(env.R("Moderator").short_hand == "Moderator", "short hand falls back to the name")
|
||||||
|
check(env.R("Moderator").color.g == 170, "color decoded")
|
||||||
|
check(env.R("Jail").priority == 1 and env.R("Jail").block_auto_assign, "priority and block auto assign decoded")
|
||||||
|
end)
|
||||||
|
|
||||||
check(eq(env.sorted(names(Roles.get_higher_roles(R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }),
|
test("roles compare on their order", function(env)
|
||||||
"higher roles include the role itself and exclude the default role")
|
env.initialise{}
|
||||||
check(eq(env.sorted(names(Roles.get_lower_roles(R("Regular")))), { "Jail", "Regular" }),
|
check(env.R("Moderator"):is_higher_than(env.R("Player")), "a lower order is more privileged")
|
||||||
"lower roles include the role itself and exclude the default role")
|
check(env.R("Player"):is_lower_than(env.R("Moderator")), "is_lower_than is the reverse")
|
||||||
|
check(not env.R("Moderator"):is_higher_than(env.R("Moderator")), "a role is not higher than itself")
|
||||||
|
end)
|
||||||
|
|
||||||
local sorted = Roles.sort_roles{ R("Player"), R("Cluster Admin"), R("Regular") }
|
test("higher and lower roles", function(env)
|
||||||
check(eq(names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first")
|
env.initialise{}
|
||||||
|
check(eq(Test.sorted(Test.names(env.Roles.get_higher_roles(env.R("Regular")))), { "Cluster Admin", "Moderator", "Regular" }),
|
||||||
|
"higher roles include the role itself and exclude the default role")
|
||||||
|
check(eq(Test.sorted(Test.names(env.Roles.get_lower_roles(env.R("Regular")))), { "Jail", "Regular" }),
|
||||||
|
"lower roles include the role itself and exclude the default role")
|
||||||
|
end)
|
||||||
|
|
||||||
return Env.results_json(env)
|
return Env.finish()
|
||||||
|
|||||||
@@ -1,56 +1,81 @@
|
|||||||
--- Player role and permission checks
|
--- Player role and permission checks
|
||||||
local Env = ...
|
local Env = ...
|
||||||
local env = Env.new()
|
local Test = Env.Test
|
||||||
local check, eq, names, R = env.check, env.eq, env.names, env.R
|
local test, check, eq = Test.test, Test.check, Test.eq
|
||||||
local Roles = env.Roles
|
|
||||||
|
|
||||||
local alice = env.add_player("alice", 1) -- moderator
|
--- alice is a moderator, bob is a regular, and carol has only the default role
|
||||||
local bob = env.add_player("bob", 2) -- regular
|
local function setup(env)
|
||||||
local carol = env.add_player("carol", 3) -- only the default role
|
local players = {
|
||||||
env.initialise{
|
alice = env.add_player("alice", 1),
|
||||||
Env.assignment("alice", { 5 }),
|
bob = env.add_player("bob", 2),
|
||||||
Env.assignment("bob", { 6 }),
|
carol = env.add_player("carol", 3),
|
||||||
}
|
}
|
||||||
|
env.initialise{
|
||||||
|
Env.assignment("alice", { 5 }),
|
||||||
|
Env.assignment("bob", { 6 }),
|
||||||
|
}
|
||||||
|
return players
|
||||||
|
end
|
||||||
|
|
||||||
check(eq(env.sorted(names(Roles.get_player_roles(alice))), { "Moderator", "Player" }),
|
test("player roles include the default role", function(env)
|
||||||
"player roles include the default role")
|
local players = setup(env)
|
||||||
check(eq(names(Roles.get_player_roles(carol)), { "Player" }), "a player with no roles has the default role")
|
check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }),
|
||||||
check(eq(names(Roles.get_player_roles(nil)), { "<server>" }), "nil is the server")
|
"held roles and the default role")
|
||||||
check(eq(names(Roles.get_player_roles(env.server)), { "<server>" }), "a player with index 0 is the server")
|
check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Player" }),
|
||||||
check(Roles.get_player_highest_role(alice) == R("Moderator"), "highest role")
|
"a player with no roles has the default role")
|
||||||
check(Roles.get_player_highest_role(carol) == R("Player"), "highest role is the default role when none are held")
|
end)
|
||||||
|
|
||||||
check(Roles.player_has_permission(alice, "exp_scenario.command.kill"), "permission through a held role")
|
test("the server is nil or a player with index 0", function(env)
|
||||||
check(Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "permission through the default role")
|
setup(env)
|
||||||
check(not Roles.player_has_permission(bob, "exp_scenario.command.jail"), "permission not granted")
|
check(eq(Test.names(env.Roles.get_player_roles(nil)), { "<server>" }), "nil is the server")
|
||||||
check(Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission")
|
check(eq(Test.names(env.Roles.get_player_roles(env.server)), { "<server>" }), "index 0 is the server")
|
||||||
check(Roles.player_has_permission(env.server, "anything.at.all"), "an index 0 player has every permission")
|
check(env.Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission")
|
||||||
|
check(not env.R("Player"):has_player(nil), "the server does not have the default role")
|
||||||
|
end)
|
||||||
|
|
||||||
check(Roles.player_has_any_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.kill"),
|
test("the highest role", function(env)
|
||||||
"has any passes when one permission is granted")
|
local players = setup(env)
|
||||||
check(not Roles.player_has_any_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.assign_role"),
|
check(env.Roles.get_player_highest_role(players.alice) == env.R("Moderator"), "highest held role")
|
||||||
"has any fails when none are granted")
|
check(env.Roles.get_player_highest_role(players.carol) == env.R("Player"), "the default role when none are held")
|
||||||
check(not Roles.player_has_any_permission(bob), "has any with no permissions is false")
|
end)
|
||||||
check(Roles.player_has_all_permission(alice, "exp_scenario.command.jail", "exp_scenario.command.kill"),
|
|
||||||
"has all passes when every permission is granted")
|
|
||||||
check(not Roles.player_has_all_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.kill"),
|
|
||||||
"has all fails when one is missing")
|
|
||||||
check(Roles.player_has_all_permission(bob), "has all with no permissions is true")
|
|
||||||
check(Roles.player_has_any_permission(nil, "anything.at.all"), "the server passes has any")
|
|
||||||
|
|
||||||
check(R("Moderator"):has_permission("exp_scenario.command.kill"), "role has_permission")
|
test("permission checks", function(env)
|
||||||
check(not R("Regular"):has_permission("exp_scenario.command.jail"), "role has_permission not granted")
|
local players = setup(env)
|
||||||
check(R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything")
|
check(env.Roles.player_has_permission(players.alice, "exp_scenario.command.kill"), "granted by a held role")
|
||||||
|
check(env.Roles.player_has_permission(players.alice, "exp_scenario.gui.readme"), "granted by the default role")
|
||||||
|
check(not env.Roles.player_has_permission(players.bob, "exp_scenario.command.jail"), "not granted")
|
||||||
|
end)
|
||||||
|
|
||||||
check(R("Moderator"):has_player(alice), "has_player for a held role")
|
test("has any and has all", function(env)
|
||||||
check(not R("Moderator"):has_player(bob), "has_player for a role not held")
|
local players = setup(env)
|
||||||
check(R("Player"):has_player(carol), "everyone has the default role")
|
local jail, kill = "exp_scenario.command.jail", "exp_scenario.command.kill"
|
||||||
check(not R("Player"):has_player(nil), "the server does not have the default role")
|
check(env.Roles.player_has_any_permission(players.bob, jail, kill), "any passes when one is granted")
|
||||||
|
check(not env.Roles.player_has_any_permission(players.bob, jail, "exp_scenario.command.assign_role"),
|
||||||
|
"any fails when none are granted")
|
||||||
|
check(not env.Roles.player_has_any_permission(players.bob), "any of none is false")
|
||||||
|
check(env.Roles.player_has_all_permission(players.alice, jail, kill), "all passes when every one is granted")
|
||||||
|
check(not env.Roles.player_has_all_permission(players.bob, jail, kill), "all fails when one is missing")
|
||||||
|
check(env.Roles.player_has_all_permission(players.bob), "all of none is true")
|
||||||
|
check(env.Roles.player_has_any_permission(nil, "anything.at.all"), "the server passes any")
|
||||||
|
end)
|
||||||
|
|
||||||
check(Roles.player_outranks(alice, bob), "a higher role outranks a lower one")
|
test("role permission and player methods", function(env)
|
||||||
check(not Roles.player_outranks(bob, alice), "a lower role does not outrank a higher one")
|
local players = setup(env)
|
||||||
check(not Roles.player_outranks(alice, alice), "nobody outranks themselves")
|
check(env.R("Moderator"):has_permission("exp_scenario.command.kill"), "has_permission granted")
|
||||||
check(Roles.player_outranks(nil, alice), "the server outranks everyone")
|
check(not env.R("Regular"):has_permission("exp_scenario.command.jail"), "has_permission not granted")
|
||||||
check(not Roles.player_outranks(alice, nil), "nobody outranks the server")
|
check(env.R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything")
|
||||||
|
check(env.R("Moderator"):has_player(players.alice), "has_player for a held role")
|
||||||
|
check(not env.R("Moderator"):has_player(players.bob), "has_player for a role not held")
|
||||||
|
check(env.R("Player"):has_player(players.carol), "everyone has the default role")
|
||||||
|
end)
|
||||||
|
|
||||||
return Env.results_json(env)
|
test("outranks", function(env)
|
||||||
|
local players = setup(env)
|
||||||
|
check(env.Roles.player_outranks(players.alice, players.bob), "a higher role outranks a lower one")
|
||||||
|
check(not env.Roles.player_outranks(players.bob, players.alice), "a lower role does not outrank a higher one")
|
||||||
|
check(not env.Roles.player_outranks(players.alice, players.alice), "nobody outranks themselves")
|
||||||
|
check(env.Roles.player_outranks(nil, players.alice), "the server outranks everyone")
|
||||||
|
check(not env.Roles.player_outranks(players.alice, nil), "nobody outranks the server")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return Env.finish()
|
||||||
|
|||||||
+101
-74
@@ -1,91 +1,118 @@
|
|||||||
--- Initialise semantics and updates from the controller
|
--- Initialise semantics and updates from the controller
|
||||||
local Env = ...
|
local Env = ...
|
||||||
local env = Env.new()
|
local Test = Env.Test
|
||||||
local check, eq, R = env.check, env.eq, env.R
|
local test, check, eq = Test.test, Test.check, Test.eq
|
||||||
local Roles = env.Roles
|
|
||||||
local sd = Roles._script_data()
|
|
||||||
|
|
||||||
local alice = env.add_player("alice", 1)
|
--- alice is a moderator and carol has only the default role, both connected
|
||||||
local carol = env.add_player("carol", 3)
|
local function setup(env)
|
||||||
|
local players = {
|
||||||
|
alice = env.add_player("alice", 1),
|
||||||
|
carol = env.add_player("carol", 3),
|
||||||
|
}
|
||||||
|
env.admin_state = {}
|
||||||
|
env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state)
|
||||||
|
env.admin_state[player.name] = state
|
||||||
|
end)
|
||||||
|
env.initialise{
|
||||||
|
Env.assignment("alice", { 5 }),
|
||||||
|
Env.assignment("zed", { 5, 6 }), -- has never joined this map
|
||||||
|
}
|
||||||
|
env.Roles.set_emit_events(true)
|
||||||
|
return players
|
||||||
|
end
|
||||||
|
|
||||||
local admin_state = {}
|
test("initialise applies triggers and raises empty events", function(env)
|
||||||
Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state)
|
setup(env)
|
||||||
admin_state[player.name] = state
|
check(env.admin_state.alice == true and env.admin_state.carol == false,
|
||||||
|
"triggers run for connected players")
|
||||||
|
check(#env.events == 2, "the event is raised once per connected player")
|
||||||
|
check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "the events are empty")
|
||||||
|
check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
env.initialise{
|
test("controller assignments apply and are announced", function(env)
|
||||||
Env.assignment("alice", { 5 }),
|
local players = setup(env)
|
||||||
Env.assignment("zed", { 5, 6 }), -- has never joined this map
|
env.reset_log()
|
||||||
}
|
env.Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) }
|
||||||
Roles.set_emit_events(true)
|
check(env.R("Moderator"):has_player(players.carol), "the assignment applies")
|
||||||
|
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event is raised")
|
||||||
|
check(env.events[1].data.by_player_index == 0, "controller changes have no by player")
|
||||||
|
check(#env.printed == 1 and env.printed[1][4] == "<server>", "the change is announced by the server")
|
||||||
|
check(env.admin_state.carol == true, "triggers follow the assignment")
|
||||||
|
|
||||||
check(admin_state.alice == true and admin_state.carol == false, "triggers run for connected players on initialise")
|
env.reset_log()
|
||||||
check(#env.events == 2, "the roles changed event is raised once per connected player on initialise")
|
env.Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } }
|
||||||
check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "initialise events are empty")
|
check(not env.R("Moderator"):has_player(players.carol), "a removal applies")
|
||||||
check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing")
|
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "the removal raises the event")
|
||||||
|
check(env.admin_state.carol == false, "triggers follow the removal")
|
||||||
|
end)
|
||||||
|
|
||||||
-- Changes to the roles a player holds, made on the controller
|
test("changes for players not on this map raise nothing", function(env)
|
||||||
env.reset_log()
|
setup(env)
|
||||||
Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) }
|
env.reset_log()
|
||||||
check(R("Moderator"):has_player(carol), "a controller assignment applies")
|
env.Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) }
|
||||||
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "a controller assignment raises the event")
|
check(#env.events == 0 and #env.printed == 0, "no event and no announcement")
|
||||||
check(env.events[1].data.by_player_index == 0, "controller changes have no by player")
|
end)
|
||||||
check(#env.printed == 1 and env.printed[1][4] == "<server>", "a controller assignment is announced by the server")
|
|
||||||
check(admin_state.carol == true, "triggers follow controller assignments")
|
|
||||||
|
|
||||||
env.reset_log()
|
test("role updates apply to their holders", function(env)
|
||||||
Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } }
|
setup(env)
|
||||||
check(not R("Moderator"):has_player(carol), "a controller removal applies")
|
env.reset_log()
|
||||||
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "a controller removal raises the event")
|
env.Roles.receive_role_updates{
|
||||||
check(admin_state.carol == false, "triggers follow controller removals")
|
{ id = 6, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } },
|
||||||
|
}
|
||||||
|
check(env.R("Regular"):has_permission("exp_scenario.command.jail"), "the permission change applies")
|
||||||
|
check(#env.events == 2, "a role change raises for every connected player")
|
||||||
|
check(#env.events[1].data.assigned == 0, "role change events are empty")
|
||||||
|
|
||||||
env.reset_log()
|
env.Roles.receive_role_updates{
|
||||||
Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) }
|
{ id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true },
|
||||||
check(#env.events == 0 and #env.printed == 0, "changes for players not on this map raise nothing")
|
}
|
||||||
|
check(env.Roles.get_role(6) == nil, "a deleted role is removed")
|
||||||
|
end)
|
||||||
|
|
||||||
-- Changes to the roles themselves
|
test("the default role follows is_default", function(env)
|
||||||
env.reset_log()
|
local players = setup(env)
|
||||||
Roles.receive_role_updates{
|
env.Roles.receive_role_updates{
|
||||||
{ id = 6, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } },
|
{ id = 1, name = "Player", permissions = {}, meta = { id = 0, order = 5 } },
|
||||||
}
|
{ id = 8, name = "Guest", permissions = {}, meta = { id = 0, order = 7 }, is_default = true },
|
||||||
check(R("Regular"):has_permission("exp_scenario.command.jail"), "a role permission change applies")
|
}
|
||||||
check(#env.events == 2, "a role change raises for every connected player")
|
check(env.Roles.get_default_role() == env.R("Guest"), "the new default role is known")
|
||||||
check(#env.events[1].data.assigned == 0, "role change events are empty")
|
check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Guest" }), "players pick up the new default")
|
||||||
|
end)
|
||||||
|
|
||||||
env.reset_log()
|
test("initialise is authoritative for pending roles", function(env)
|
||||||
Roles.receive_role_updates{ { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true } }
|
local players = setup(env)
|
||||||
check(Roles.get_role(6) == nil, "a deleted role is removed")
|
env.R("Moderator"):assign(players.carol)
|
||||||
|
env.R("Jail"):assign(players.carol, { silent = true, local_only = true })
|
||||||
|
|
||||||
env.reset_log()
|
local sd = env.Roles._script_data()
|
||||||
Roles.receive_role_updates{
|
check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise")
|
||||||
{ id = 1, name = "Player", permissions = {}, meta = { id = 0, order = 5 } },
|
|
||||||
{ id = 8, name = "Guest", permissions = {}, meta = { id = 0, order = 7 }, is_default = true },
|
|
||||||
}
|
|
||||||
check(Roles.get_default_role() == R("Guest"), "the default role follows is_default")
|
|
||||||
check(eq(env.names(Roles.get_player_roles(carol)), { "Guest" }), "players pick up the new default role")
|
|
||||||
|
|
||||||
-- Initialise is authoritative for pending roles
|
env.reset_log()
|
||||||
R("Moderator"):assign(carol)
|
env.initialise{ Env.assignment("alice", { 5 }) }
|
||||||
R("Jail"):assign(carol, { silent = true, local_only = true })
|
check(sd.pending.carol == nil, "pending roles are cleared")
|
||||||
check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise")
|
check(not env.R("Moderator"):has_player(players.carol), "an unconfirmed role is given up")
|
||||||
env.reset_log()
|
check(eq(sd.local_players.carol, { 7 }), "local only roles are kept")
|
||||||
env.initialise{ Env.assignment("alice", { 5 }) }
|
check(#env.sent == 0, "initialise sends nothing")
|
||||||
check(sd.pending.carol == nil, "initialise clears pending roles")
|
end)
|
||||||
check(not R("Moderator"):has_player(carol), "an unconfirmed role is given up")
|
|
||||||
check(eq(sd.local_players.carol, { 7 }), "local only roles are kept")
|
|
||||||
check(#env.sent == 0, "initialise sends nothing")
|
|
||||||
|
|
||||||
-- Emit updates gate and load
|
test("nothing is sent while emit is disabled", function(env)
|
||||||
Roles.set_emit_events(false)
|
local players = setup(env)
|
||||||
env.reset_log()
|
env.Roles.set_emit_events(false)
|
||||||
R("Regular"):assign(alice)
|
env.reset_log()
|
||||||
check(#env.sent == 0, "nothing is sent while emit is disabled")
|
env.R("Regular"):assign(players.alice)
|
||||||
Roles.on_load()
|
check(#env.sent == 0, "the assignment is not sent")
|
||||||
check(R("Moderator"):is_higher_than(R("Regular")), "roles are usable after load")
|
check(env.R("Regular"):has_player(players.alice), "the assignment still applies locally")
|
||||||
|
end)
|
||||||
|
|
||||||
-- Triggers on join
|
test("roles are usable after load and triggers run on join", function(env)
|
||||||
admin_state.alice = nil
|
setup(env)
|
||||||
Roles.on_player_joined_game{ player_index = 1 }
|
env.Roles.on_load()
|
||||||
check(admin_state.alice ~= nil, "triggers run when a player joins")
|
check(env.R("Moderator"):is_higher_than(env.R("Regular")), "roles are usable after load")
|
||||||
|
|
||||||
return Env.results_json(env)
|
env.admin_state.alice = nil
|
||||||
|
env.Roles.on_player_joined_game{ player_index = 1 }
|
||||||
|
check(env.admin_state.alice ~= nil, "triggers run when a player joins")
|
||||||
|
end)
|
||||||
|
|
||||||
|
return Env.finish()
|
||||||
|
|||||||
@@ -1,31 +1,83 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
const t = require("tap");
|
const t = require("tap");
|
||||||
const { Value } = require("@sinclair/typebox/value");
|
|
||||||
const messages = require("../dist/node/messages");
|
const messages = require("../dist/node/messages");
|
||||||
|
const { testMatrix, testRoundTripJsonSerialisable } = require("../../test/common");
|
||||||
|
|
||||||
function roundTrip(subtest, Record, record) {
|
const fullMeta = new messages.RoleMetaRecord(
|
||||||
const json = record.toJSON();
|
7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false,
|
||||||
subtest.ok(Value.Check(Record.jsonSchema, json), "json matches the schema");
|
);
|
||||||
subtest.strictSame(Record.fromJSON(json), record, "the record round trips");
|
|
||||||
}
|
t.test("RoleColor", subtest => {
|
||||||
|
testRoundTripJsonSerialisable(messages.RoleColor, testMatrix(
|
||||||
|
[0, 255], // r
|
||||||
|
[0, 128], // g
|
||||||
|
[0, 1], // b
|
||||||
|
));
|
||||||
|
subtest.pass("round trips");
|
||||||
|
subtest.end();
|
||||||
|
});
|
||||||
|
|
||||||
t.test("RoleMetaRecord", subtest => {
|
t.test("RoleMetaRecord", subtest => {
|
||||||
roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord(7, 3));
|
testRoundTripJsonSerialisable(messages.RoleMetaRecord, testMatrix(
|
||||||
roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord(
|
[7], // id
|
||||||
7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false,
|
[3], // order
|
||||||
|
[0, 1], // priority
|
||||||
|
["", "Mod"], // shortHand
|
||||||
|
["", "[Mod]"], // tag
|
||||||
|
[null, new messages.RoleColor(1, 2, 3)], // color
|
||||||
|
[null, 3600000], // autoAssignOnlineTimeMs
|
||||||
|
[false, true], // blockAutoAssign
|
||||||
|
[0, 12345], // updatedAtMs
|
||||||
|
[false, true], // isDeleted
|
||||||
));
|
));
|
||||||
|
subtest.pass("round trips");
|
||||||
subtest.end();
|
subtest.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
t.test("RoleRecord", subtest => {
|
t.test("RoleRecord", subtest => {
|
||||||
roundTrip(subtest, messages.RoleRecord, new messages.RoleRecord(
|
testRoundTripJsonSerialisable(messages.RoleRecord, testMatrix(
|
||||||
7, "Moderator", ["exp_scenario.command.kill"], new messages.RoleMetaRecord(7, 3), true, 12345,
|
[7], // id
|
||||||
|
["Moderator"], // name
|
||||||
|
[[], ["a.b", "c.d"]], // permissions
|
||||||
|
[new messages.RoleMetaRecord(7, 3), fullMeta], // meta
|
||||||
|
[false, true], // isDefault
|
||||||
|
[0, 12345], // updatedAtMs
|
||||||
|
[false, true], // isDeleted
|
||||||
));
|
));
|
||||||
|
subtest.pass("round trips");
|
||||||
subtest.end();
|
subtest.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
t.test("AssignmentRecord", subtest => {
|
t.test("AssignmentRecord", subtest => {
|
||||||
roundTrip(subtest, messages.AssignmentRecord, new messages.AssignmentRecord("alice", new Set([5, 6]), 12345));
|
testRoundTripJsonSerialisable(messages.AssignmentRecord, testMatrix(
|
||||||
|
["alice"], // name
|
||||||
|
[new Set(), new Set([5, 6])], // roleIds
|
||||||
|
[0, 12345], // updatedAtMs
|
||||||
|
[false, true], // isDeleted
|
||||||
|
));
|
||||||
|
subtest.pass("round trips");
|
||||||
|
subtest.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
t.test("update events and requests", subtest => {
|
||||||
|
const record = new messages.RoleRecord(7, "Moderator", ["a.b"], fullMeta, true, 12345);
|
||||||
|
const assignment = new messages.AssignmentRecord("alice", new Set([5]), 12345);
|
||||||
|
|
||||||
|
testRoundTripJsonSerialisable(messages.RoleUpdatedEvent, testMatrix(
|
||||||
|
[[], [record]], // updates
|
||||||
|
));
|
||||||
|
testRoundTripJsonSerialisable(messages.AssignmentUpdatedEvent, testMatrix(
|
||||||
|
[[], [assignment]], // updates
|
||||||
|
));
|
||||||
|
testRoundTripJsonSerialisable(messages.RoleMetaUpdateRequest, testMatrix(
|
||||||
|
[new messages.RoleMetaRecord(7, 3), fullMeta], // meta
|
||||||
|
));
|
||||||
|
testRoundTripJsonSerialisable(messages.AssignmentUpdateRequest, testMatrix(
|
||||||
|
["alice"], // name
|
||||||
|
[[], [5]], // assign
|
||||||
|
[[], [6]], // unassign
|
||||||
|
));
|
||||||
|
subtest.pass("round trips");
|
||||||
subtest.end();
|
subtest.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
const path = require("node:path");
|
||||||
const t = require("tap");
|
const t = require("tap");
|
||||||
const { reportLuaTests } = require("./helpers/lua");
|
const { reportLuaTests } = require("../../test/lua/runner");
|
||||||
|
|
||||||
// Each file runs in its own lua state with a fresh copy of the module
|
const envFile = path.join(__dirname, "lua", "env.lua");
|
||||||
|
|
||||||
|
// Each file runs in its own lua state, and each test in a fresh environment
|
||||||
for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) {
|
for (const file of ["lookup.lua", "players.lua", "assignment.lua", "sync.lua", "holders.lua"]) {
|
||||||
t.test(file, subtest => reportLuaTests(subtest, file));
|
t.test(file, subtest => reportLuaTests(subtest, envFile, path.join(__dirname, "lua", file)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use strict";
|
||||||
|
const assert = require("assert").strict;
|
||||||
|
const { compile } = require("@clusterio/lib");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a flat array of tests from a matrix of inputs.
|
||||||
|
*
|
||||||
|
* @template {any[][]} T
|
||||||
|
* @param {T} arrays - A list of arrays representing the different values for each argument
|
||||||
|
* @returns {Array<{ [K in keyof T]: T[K][number] }>}
|
||||||
|
* An array of tuples, each containing one value from each input array.
|
||||||
|
*/
|
||||||
|
function testMatrix(...arrays) {
|
||||||
|
return arrays.reduce((acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])), [[]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that a class is round trip json serialisable across multiple test cases.
|
||||||
|
*
|
||||||
|
* @template {any[]} T - Constructor arguments
|
||||||
|
* @param {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods.
|
||||||
|
* @param {T[]} tests - The tests inputs to pass to the class constructor.
|
||||||
|
*/
|
||||||
|
function testRoundTripJsonSerialisable(Class, tests) {
|
||||||
|
const validate = compile(Class.jsonSchema);
|
||||||
|
for (const test of tests) {
|
||||||
|
const original = new Class(...test);
|
||||||
|
const serialised = JSON.stringify(original);
|
||||||
|
const jsonObject = JSON.parse(serialised);
|
||||||
|
const reconstructed = Class.fromJSON(jsonObject);
|
||||||
|
assert.deepEqual(reconstructed, original, JSON.stringify(test));
|
||||||
|
if (!validate(jsonObject)) {
|
||||||
|
throw validate.errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
testMatrix,
|
||||||
|
testRoundTripJsonSerialisable,
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
--[[-- Test framework for plugin module tests
|
||||||
|
Test files declare named tests with `Test.test(name, function(env) ... end)`.
|
||||||
|
The runner creates a fresh environment for every test, so tests are
|
||||||
|
independent and can not leak state into each other.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Framework = {}
|
||||||
|
|
||||||
|
--- Create a test registry, one is made per test file
|
||||||
|
function Framework.new()
|
||||||
|
--- @class Test
|
||||||
|
local Test = {
|
||||||
|
tests = {}, -- { name, fn } in declaration order
|
||||||
|
results = {}, -- { name, ok, detail? } accumulated across tests
|
||||||
|
}
|
||||||
|
|
||||||
|
local current_test --- @type string?
|
||||||
|
|
||||||
|
--- Declare a named test, its function receives a fresh environment
|
||||||
|
--- @param name string
|
||||||
|
--- @param fn fun(env: table)
|
||||||
|
function Test.test(name, fn)
|
||||||
|
Test.tests[#Test.tests + 1] = { name = name, fn = fn }
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Record one check within the current test
|
||||||
|
--- @param ok any Truthy when the check passed
|
||||||
|
--- @param name string
|
||||||
|
--- @param detail string?
|
||||||
|
function Test.check(ok, name, detail)
|
||||||
|
Test.results[#Test.results + 1] = {
|
||||||
|
name = current_test and (current_test .. ": " .. name) or name,
|
||||||
|
ok = not not ok,
|
||||||
|
detail = detail,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Shallow array equality
|
||||||
|
function Test.eq(a, b)
|
||||||
|
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
|
||||||
|
if #a ~= #b then return false end
|
||||||
|
for i = 1, #a do
|
||||||
|
if a[i] ~= b[i] then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Recursive table equality, keys checked from both sides
|
||||||
|
function Test.deep_eq(a, b)
|
||||||
|
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
|
||||||
|
for key, value in pairs(a) do
|
||||||
|
if not Test.deep_eq(value, b[key]) then return false end
|
||||||
|
end
|
||||||
|
for key in pairs(b) do
|
||||||
|
if a[key] == nil then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Names of an array of roles
|
||||||
|
function Test.names(roles)
|
||||||
|
local rtn = {}
|
||||||
|
for index, role in ipairs(roles) do
|
||||||
|
rtn[index] = role.name
|
||||||
|
end
|
||||||
|
return rtn
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Sorted copy of an array of strings
|
||||||
|
function Test.sorted(list)
|
||||||
|
local rtn = {}
|
||||||
|
for index, value in ipairs(list) do rtn[index] = value end
|
||||||
|
table.sort(rtn)
|
||||||
|
return rtn
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Run every declared test against a fresh environment
|
||||||
|
--- @param make_env fun(): table
|
||||||
|
function Test.run(make_env)
|
||||||
|
for _, test in ipairs(Test.tests) do
|
||||||
|
current_test = test.name
|
||||||
|
local ok, err = pcall(test.fn, make_env())
|
||||||
|
if not ok then
|
||||||
|
Test.check(false, "did not error", tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
current_test = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Encode the results as JSON for the javascript side
|
||||||
|
function Test.results_json()
|
||||||
|
local function escape(value)
|
||||||
|
return (value:gsub('[%c"\\]', function(c)
|
||||||
|
return string.format("\\u%04x", c:byte())
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
|
local parts = {}
|
||||||
|
for index, result in ipairs(Test.results) do
|
||||||
|
local detail = result.detail and string.format(',"detail":"%s"', escape(result.detail)) or ""
|
||||||
|
parts[index] = string.format('{"name":"%s","ok":%s%s}', escape(result.name), tostring(result.ok), detail)
|
||||||
|
end
|
||||||
|
return "[" .. table.concat(parts, ",") .. "]"
|
||||||
|
end
|
||||||
|
|
||||||
|
return Test
|
||||||
|
end
|
||||||
|
|
||||||
|
return Framework
|
||||||
@@ -3,28 +3,30 @@ const path = require("node:path");
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari");
|
const { lua, lauxlib, lualib, to_luastring, to_jsstring } = require("fengari");
|
||||||
|
|
||||||
const moduleRoot = path.join(__dirname, "..", "..", "module");
|
|
||||||
const luaRoot = path.join(__dirname, "..", "lua");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one lua test file in its own lua state and return its results.
|
* Run one lua test file in its own lua state and return its results.
|
||||||
*
|
*
|
||||||
* The state is created here, on first use by the caller, so every test file
|
* The state is created here, on first use by the caller, so every test file
|
||||||
* gets an independent environment. The file receives the environment helpers
|
* gets an independent set of stubs. Three chunks run in order, each taking one
|
||||||
* from env.lua and must return an array of { name, ok, detail? } results,
|
* argument and returning one value:
|
||||||
* encoded as JSON.
|
|
||||||
*
|
*
|
||||||
* @param {string} name - File in test/lua to run, such as "lookup.lua".
|
* 1. The plugin's env.lua, receiving this directory so it can load the shared
|
||||||
|
* stubs and framework, returning its environment module.
|
||||||
|
* 2. The test file, receiving the environment module, declaring its tests.
|
||||||
|
* 3. The tests then run, each against a fresh environment, and the results
|
||||||
|
* are returned as JSON.
|
||||||
|
*
|
||||||
|
* @param {string} envFile - Absolute path of the plugin's test/lua/env.lua.
|
||||||
|
* @param {string} testFile - Absolute path of the lua test file to run.
|
||||||
* @returns {{ name: string, ok: boolean, detail?: string }[]}
|
* @returns {{ name: string, ok: boolean, detail?: string }[]}
|
||||||
*/
|
*/
|
||||||
function runLuaTests(name) {
|
function runLuaTests(envFile, testFile) {
|
||||||
const L = lauxlib.luaL_newstate();
|
const L = lauxlib.luaL_newstate();
|
||||||
lualib.luaL_openlibs(L);
|
lualib.luaL_openlibs(L);
|
||||||
|
|
||||||
// Each file is run as a chunk taking one argument and returning one value
|
const load = (file) => {
|
||||||
const load = (file, chunkName) => {
|
|
||||||
const code = fs.readFileSync(file);
|
const code = fs.readFileSync(file);
|
||||||
const status = lauxlib.luaL_loadbuffer(L, code, code.length, to_luastring(`@${chunkName}`));
|
const status = lauxlib.luaL_loadbuffer(L, code, code.length, to_luastring(`@${file}`));
|
||||||
if (status !== lua.LUA_OK) {
|
if (status !== lua.LUA_OK) {
|
||||||
throw new Error(to_jsstring(lua.lua_tostring(L, -1)));
|
throw new Error(to_jsstring(lua.lua_tostring(L, -1)));
|
||||||
}
|
}
|
||||||
@@ -35,13 +37,12 @@ function runLuaTests(name) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The environment stubs factorio and loads module/control.lua from disk
|
load(envFile);
|
||||||
load(path.join(luaRoot, "env.lua"), "test/lua/env.lua");
|
lua.lua_pushstring(L, to_luastring(__dirname));
|
||||||
lua.lua_pushstring(L, to_luastring(moduleRoot));
|
|
||||||
call();
|
call();
|
||||||
|
|
||||||
// The environment stays on the stack and is passed to the test chunk
|
// The environment stays on the stack and is passed to the test chunk
|
||||||
load(path.join(luaRoot, name), `test/lua/${name}`);
|
load(testFile);
|
||||||
lua.lua_insert(L, -2);
|
lua.lua_insert(L, -2);
|
||||||
call();
|
call();
|
||||||
|
|
||||||
@@ -49,10 +50,10 @@ function runLuaTests(name) {
|
|||||||
return JSON.parse(json);
|
return JSON.parse(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Report lua results through a tap test object. */
|
/** Report the results of a lua test file through a tap test object. */
|
||||||
function reportLuaTests(t, name) {
|
function reportLuaTests(t, envFile, testFile) {
|
||||||
const results = runLuaTests(name);
|
const results = runLuaTests(envFile, testFile);
|
||||||
t.ok(results.length > 0, `${name} produced results`);
|
t.ok(results.length > 0, `${path.basename(testFile)} produced results`);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
t.ok(result.ok, result.name, result.detail ? { detail: result.detail } : {});
|
t.ok(result.ok, result.name, result.detail ? { detail: result.detail } : {});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
--[[-- Generic factorio stubs for plugin module tests
|
||||||
|
Provides the surface a clusterio module touches, recording what the module does
|
||||||
|
to it. Every stub raises an error when an unimplemented property is read, which
|
||||||
|
mirrors the game api and catches mistakes in tests early.
|
||||||
|
|
||||||
|
Plugins compose these from their own test/lua/env.lua, adding stubs of their
|
||||||
|
own through `stubs.requires` and `Stubs.strict`.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Stubs = {}
|
||||||
|
|
||||||
|
--- Give a table an index metamethod which raises on unknown properties
|
||||||
|
--- @generic T : table
|
||||||
|
--- @param name string Shown in the error message
|
||||||
|
--- @param tbl T
|
||||||
|
--- @param allowed_nil string[]? Properties which may be read while unset
|
||||||
|
--- @return T
|
||||||
|
function Stubs.strict(name, tbl, allowed_nil)
|
||||||
|
local allowed = {}
|
||||||
|
for _, key in pairs(allowed_nil or {}) do
|
||||||
|
allowed[key] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
return setmetatable(tbl, {
|
||||||
|
__index = function(_, key)
|
||||||
|
if allowed[key] then return nil end
|
||||||
|
error(name .. " does not implement: " .. tostring(key), 2)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Create an independent set of stubs, installed as the lua globals
|
||||||
|
function Stubs.new()
|
||||||
|
local stubs = {
|
||||||
|
events = {}, -- events raised through script.raise_event
|
||||||
|
printed = {}, -- game.print and player.print messages
|
||||||
|
sent = {}, -- payloads sent through the clusterio api
|
||||||
|
sounds = {}, -- sounds played to players
|
||||||
|
}
|
||||||
|
|
||||||
|
local next_event_id = 100
|
||||||
|
script = Stubs.strict("script", {
|
||||||
|
generate_event_name = function()
|
||||||
|
next_event_id = next_event_id + 1
|
||||||
|
return next_event_id
|
||||||
|
end,
|
||||||
|
raise_event = function(id, data)
|
||||||
|
stubs.events[#stubs.events + 1] = { id = id, data = data }
|
||||||
|
end,
|
||||||
|
register_metatable = function() end,
|
||||||
|
})
|
||||||
|
|
||||||
|
defines = Stubs.strict("defines", {
|
||||||
|
events = Stubs.strict("defines.events", {
|
||||||
|
on_player_joined_game = 1,
|
||||||
|
on_multiplayer_init = 2,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
local players_by_name, players_by_index, connected = {}, {}, {}
|
||||||
|
game = Stubs.strict("game", {
|
||||||
|
tick = 1,
|
||||||
|
players = players_by_name,
|
||||||
|
connected_players = connected,
|
||||||
|
print = function(message) stubs.printed[#stubs.printed + 1] = message end,
|
||||||
|
get_player = function(key) return players_by_name[key] or players_by_index[key] end,
|
||||||
|
}, { "player" })
|
||||||
|
|
||||||
|
--- Add a player to the stubbed game
|
||||||
|
function stubs.add_player(name, index, is_connected)
|
||||||
|
local player = Stubs.strict("LuaPlayer " .. name, {
|
||||||
|
name = name,
|
||||||
|
index = index,
|
||||||
|
connected = is_connected ~= false,
|
||||||
|
valid = true,
|
||||||
|
play_sound = function(opts)
|
||||||
|
stubs.sounds[#stubs.sounds + 1] = name .. ":" .. opts.path
|
||||||
|
end,
|
||||||
|
print = function(message)
|
||||||
|
stubs.printed[#stubs.printed + 1] = { to = name, message }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
players_by_name[name] = player
|
||||||
|
players_by_index[index] = player
|
||||||
|
if player.connected then connected[#connected + 1] = player end
|
||||||
|
return player
|
||||||
|
end
|
||||||
|
|
||||||
|
--- A player object which represents the server
|
||||||
|
stubs.server = Stubs.strict("server player", { index = 0, name = "<server>" })
|
||||||
|
|
||||||
|
--- Modules resolved by the stubbed require, extend before loading the module
|
||||||
|
stubs.requires = {
|
||||||
|
["modules/clusterio/api"] = Stubs.strict("clusterio api", {
|
||||||
|
send_json = function(channel, data)
|
||||||
|
stubs.sent[#stubs.sent + 1] = { channel = channel, data = data }
|
||||||
|
end,
|
||||||
|
}),
|
||||||
|
["modules/clusterio/compat"] = Stubs.strict("clusterio compat", { script_data = {} }),
|
||||||
|
["modules/exp_util/async"] = Stubs.strict("exp_util async", {
|
||||||
|
register = function(callback)
|
||||||
|
return function(...) return callback(...) end
|
||||||
|
end,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
require = function(name)
|
||||||
|
return assert(stubs.requires[name], "Unexpected require: " .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Forget everything recorded so far
|
||||||
|
function stubs.reset_log()
|
||||||
|
stubs.events, stubs.printed, stubs.sent, stubs.sounds = {}, {}, {}, {}
|
||||||
|
end
|
||||||
|
|
||||||
|
return stubs
|
||||||
|
end
|
||||||
|
|
||||||
|
return Stubs
|
||||||
Reference in New Issue
Block a user