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
|
||||
local Env = ...
|
||||
local env = Env.new()
|
||||
local check, eq, R = env.check, env.eq, env.R
|
||||
local Roles = env.Roles
|
||||
local sd = Roles._script_data()
|
||||
local Test = Env.Test
|
||||
local test, check, eq = Test.test, Test.check, Test.eq
|
||||
|
||||
local alice = env.add_player("alice", 1)
|
||||
local bob = env.add_player("bob", 2)
|
||||
env.initialise{
|
||||
--- alice is a moderator and bob a regular, with changes sent to the controller
|
||||
local function setup(env)
|
||||
local players = {
|
||||
alice = env.add_player("alice", 1),
|
||||
bob = env.add_player("bob", 2),
|
||||
}
|
||||
env.initialise{
|
||||
Env.assignment("alice", { 5 }),
|
||||
Env.assignment("bob", { 6 }),
|
||||
}
|
||||
Roles.set_emit_events(true)
|
||||
env.reset_log()
|
||||
}
|
||||
env.Roles.set_emit_events(true)
|
||||
env.reset_log()
|
||||
return players
|
||||
end
|
||||
|
||||
-- Assign with sync
|
||||
R("Moderator"):assign(bob, { by_player_name = "alice" })
|
||||
check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "assignment is sent to the controller")
|
||||
check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "assignment payload")
|
||||
check(R("Moderator"):has_player(bob), "the role applies before confirmation")
|
||||
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "event carries the assigned role id")
|
||||
check(env.events[1].data.by_player_index == 1, "event carries who made the change")
|
||||
check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced")
|
||||
check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "assign sound")
|
||||
check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "held locally and pending")
|
||||
test("assign applies locally and is sent to the controller", function(env)
|
||||
local players = setup(env)
|
||||
env.R("Moderator"):assign(players.bob, { by_player_name = "alice" })
|
||||
check(#env.sent == 1 and env.sent[1].channel == "exp_roles:assignment_update", "the assignment is sent")
|
||||
check(eq(env.sent[1].data.assign, { 5 }) and env.sent[1].data.name == "bob", "the payload names the role and player")
|
||||
check(env.R("Moderator"):has_player(players.bob), "the role applies before confirmation")
|
||||
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "the event carries the assigned role id")
|
||||
check(env.events[1].data.by_player_index == 1, "the event carries who made the change")
|
||||
check(#env.printed == 1 and env.printed[1][1] == "exp-roles.game-message-assign", "the change is announced")
|
||||
check(#env.sounds == 1 and env.sounds[1] == "bob:utility/achievement_unlocked", "the assign sound plays")
|
||||
|
||||
env.reset_log()
|
||||
R("Moderator"):assign(bob)
|
||||
check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op")
|
||||
local sd = env.Roles._script_data()
|
||||
check(eq(sd.local_players.bob, { 5 }) and eq(sd.pending.bob, { 5 }), "the role is held locally and pending")
|
||||
|
||||
-- Controller confirms
|
||||
env.reset_log()
|
||||
Roles.receive_assignment_updates{ Env.assignment("bob", { 6, 5 }) }
|
||||
check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing")
|
||||
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")
|
||||
env.reset_log()
|
||||
env.R("Moderator"):assign(players.bob)
|
||||
check(#env.sent == 0 and #env.events == 0, "assigning a held role is a no-op")
|
||||
end)
|
||||
|
||||
-- Unassign a synced role
|
||||
env.reset_log()
|
||||
R("Moderator"):unassign(bob, { by_player_name = "alice", silent = true })
|
||||
check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 5 }), "unassignment is sent to the controller")
|
||||
check(not R("Moderator"):has_player(bob), "the role is removed locally")
|
||||
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "event carries the unassigned role id")
|
||||
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 }) }
|
||||
test("a confirmation releases the local hold", function(env)
|
||||
local players = setup(env)
|
||||
env.R("Moderator"):assign(players.bob)
|
||||
env.reset_log()
|
||||
env.Roles.receive_assignment_updates{ Env.assignment("bob", { 6, 5 }) }
|
||||
check(#env.events == 0 and #env.printed == 0, "a confirmation raises nothing")
|
||||
|
||||
-- Rejection rolls back
|
||||
env.reset_log()
|
||||
R("Jail"):assign(alice)
|
||||
check(R("Jail"):has_player(alice), "the role applies before rejection")
|
||||
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 sd = env.Roles._script_data()
|
||||
check(sd.local_players.bob == nil and sd.pending.bob == nil, "the role is no longer held locally")
|
||||
check(env.R("Moderator"):has_player(players.bob), "the role is still held after confirmation")
|
||||
end)
|
||||
|
||||
-- Local only roles
|
||||
env.reset_log()
|
||||
R("Regular"):assign(alice, { silent = true, local_only = true })
|
||||
check(#env.sent == 0, "a local only role is not sent")
|
||||
check(R("Regular"):has_player(alice), "a local only role applies")
|
||||
check(sd.pending.alice == nil and eq(sd.local_players.alice, { 6 }), "a local only role is not pending")
|
||||
Roles.receive_assignment_updates{ Env.assignment("alice", { 5 }) }
|
||||
check(R("Regular"):has_player(alice), "a local only role survives controller updates")
|
||||
env.reset_log()
|
||||
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")
|
||||
test("unassign a synced role", function(env)
|
||||
local players = setup(env)
|
||||
env.R("Regular"):unassign(players.bob, { by_player_name = "alice", silent = true })
|
||||
check(#env.sent == 1 and eq(env.sent[1].data.unassign, { 6 }), "the unassignment is sent")
|
||||
check(not env.R("Regular"):has_player(players.bob), "the role is removed locally")
|
||||
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 6 }), "the event carries the unassigned role id")
|
||||
check(#env.printed == 0, "silent suppresses the announcement")
|
||||
check(#env.sounds == 1 and env.sounds[1] == "bob:utility/game_lost", "the unassign sound plays")
|
||||
end)
|
||||
|
||||
-- Jail priority
|
||||
env.reset_log()
|
||||
R("Jail"):assign(alice, { by_player_name = "<server>", silent = true })
|
||||
check(eq(env.names(Roles.get_player_roles(alice)), { "Jail" }), "jail suppresses every other role")
|
||||
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 }),
|
||||
test("a rejection rolls the assignment back", function(env)
|
||||
local players = setup(env)
|
||||
env.R("Jail"):assign(players.alice)
|
||||
check(env.R("Jail"):has_player(players.alice), "the role applies before rejection")
|
||||
|
||||
env.reset_log()
|
||||
env.Roles.reject_assignment{ name = "alice", role_ids = { 7 } }
|
||||
check(not env.R("Jail"):has_player(players.alice), "the 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")
|
||||
|
||||
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)
|
||||
|
||||
-- The server can not be assigned roles
|
||||
env.reset_log()
|
||||
R("Moderator"):assign(env.server)
|
||||
check(#env.sent == 0 and #env.events == 0, "assigning to the server is a no-op")
|
||||
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.results_json(env)
|
||||
return Env.finish()
|
||||
|
||||
+21
-130
@@ -1,14 +1,23 @@
|
||||
--[[-- Test environment for module/control.lua
|
||||
Stubs the factorio globals and the clusterio modules, then loads a fresh copy
|
||||
of the roles module. Each call to Env.new() is independent.
|
||||
Composes the shared stubs and framework from the repository test folder with
|
||||
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.
|
||||
]]
|
||||
|
||||
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
|
||||
Env.permission_names = {
|
||||
@@ -44,83 +53,12 @@ end
|
||||
|
||||
--- Create an independent environment with a fresh copy of the roles module
|
||||
function Env.new()
|
||||
local env = {
|
||||
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 env = Stubs.new()
|
||||
|
||||
local next_event_id = 100
|
||||
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 = assert(loadfile(plugin_root .. "/module/control.lua"))()
|
||||
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)
|
||||
return (assert(env.Roles.get_role_by_name(name), "No role named " .. name))
|
||||
end
|
||||
@@ -134,60 +72,13 @@ function Env.new()
|
||||
}
|
||||
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
|
||||
end
|
||||
|
||||
--- Encode the results of an environment as JSON for the javascript side
|
||||
function Env.results_json(env)
|
||||
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(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, ",") .. "]"
|
||||
--- Run the declared tests, each against a fresh environment
|
||||
function Env.finish()
|
||||
Env.Test.run(Env.new)
|
||||
return Env.Test.results_json()
|
||||
end
|
||||
|
||||
return Env
|
||||
|
||||
@@ -1,47 +1,76 @@
|
||||
--- Listing and printing to the players who hold a role
|
||||
local Env = ...
|
||||
local env = Env.new()
|
||||
local check, eq, R = env.check, env.eq, env.R
|
||||
local Roles = env.Roles
|
||||
local Test = Env.Test
|
||||
local test, check, eq = Test.test, Test.check, Test.eq
|
||||
|
||||
local alice = env.add_player("alice", 1)
|
||||
env.add_player("bob", 2)
|
||||
env.add_player("dave", 4, false) -- offline
|
||||
env.initialise{
|
||||
--- alice and dave are moderators with dave offline, zed has never joined
|
||||
local function setup(env)
|
||||
local players = {
|
||||
alice = env.add_player("alice", 1),
|
||||
bob = env.add_player("bob", 2),
|
||||
dave = env.add_player("dave", 4, false),
|
||||
}
|
||||
env.initialise{
|
||||
Env.assignment("alice", { 5 }),
|
||||
Env.assignment("bob", { 6 }),
|
||||
Env.assignment("dave", { 5 }),
|
||||
Env.assignment("zed", { 5 }), -- has never joined this map
|
||||
}
|
||||
Roles.set_emit_events(true)
|
||||
|
||||
local mod = R("Moderator")
|
||||
check(eq(env.sorted(mod:get_player_names()), { "alice", "dave", "zed" }),
|
||||
"player names include players not on this map")
|
||||
check(eq(env.sorted(env.names(mod:get_players())), { "alice", "dave" }),
|
||||
"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")
|
||||
Env.assignment("zed", { 5 }),
|
||||
}
|
||||
env.Roles.set_emit_events(true)
|
||||
return players
|
||||
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
|
||||
local Env = ...
|
||||
local env = Env.new()
|
||||
local check, eq, names, R = env.check, env.eq, env.names, env.R
|
||||
local Roles = env.Roles
|
||||
local Test = Env.Test
|
||||
local test, check, eq = Test.test, Test.check, Test.eq
|
||||
|
||||
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" }),
|
||||
"ordered roles are most privileged first with deleted roles skipped")
|
||||
check(#Roles.get_roles() == 5, "get_roles returns every role")
|
||||
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")
|
||||
local sorted = env.Roles.sort_roles{ env.R("Player"), env.R("Cluster Admin"), env.R("Regular") }
|
||||
check(eq(Test.names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first")
|
||||
end)
|
||||
|
||||
check(R("Moderator").short_hand == "Mod" or R("Moderator").short_hand == "Moderator",
|
||||
"short hand falls back to the name")
|
||||
check(R("Cluster Admin").short_hand == "SYS", "short hand decoded")
|
||||
check(R("Moderator").color.g == 170, "color decoded")
|
||||
check(R("Jail").priority == 1 and R("Jail").block_auto_assign, "priority and block auto assign decoded")
|
||||
test("roles are looked up by id", function(env)
|
||||
env.initialise{}
|
||||
check(env.Roles.get_role(6).name == "Regular", "get_role looks up by clusterio id")
|
||||
check(env.Roles.get_role_by_name("Moderator").id == 5, "get_role_by_name searches the roles")
|
||||
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")
|
||||
check(R("Player"):is_lower_than(R("Moderator")), "is_lower_than is the reverse")
|
||||
check(not R("Moderator"):is_higher_than(R("Moderator")), "a role is not higher than itself")
|
||||
test("role records are decoded", function(env)
|
||||
env.initialise{}
|
||||
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)
|
||||
env.initialise{}
|
||||
check(env.R("Moderator"):is_higher_than(env.R("Player")), "a lower order is more privileged")
|
||||
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)
|
||||
|
||||
test("higher and lower roles", function(env)
|
||||
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(env.sorted(names(Roles.get_lower_roles(R("Regular")))), { "Jail", "Regular" }),
|
||||
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)
|
||||
|
||||
local sorted = Roles.sort_roles{ R("Player"), R("Cluster Admin"), R("Regular") }
|
||||
check(eq(names(sorted), { "Cluster Admin", "Regular", "Player" }), "sort_roles orders most privileged first")
|
||||
|
||||
return Env.results_json(env)
|
||||
return Env.finish()
|
||||
|
||||
@@ -1,56 +1,81 @@
|
||||
--- Player role and permission checks
|
||||
local Env = ...
|
||||
local env = Env.new()
|
||||
local check, eq, names, R = env.check, env.eq, env.names, env.R
|
||||
local Roles = env.Roles
|
||||
local Test = Env.Test
|
||||
local test, check, eq = Test.test, Test.check, Test.eq
|
||||
|
||||
local alice = env.add_player("alice", 1) -- moderator
|
||||
local bob = env.add_player("bob", 2) -- regular
|
||||
local carol = env.add_player("carol", 3) -- only the default role
|
||||
env.initialise{
|
||||
--- alice is a moderator, bob is a regular, and carol has only the default role
|
||||
local function setup(env)
|
||||
local players = {
|
||||
alice = env.add_player("alice", 1),
|
||||
bob = env.add_player("bob", 2),
|
||||
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" }),
|
||||
"player roles include the default role")
|
||||
check(eq(names(Roles.get_player_roles(carol)), { "Player" }), "a player with no roles has the default role")
|
||||
check(eq(names(Roles.get_player_roles(nil)), { "<server>" }), "nil is the server")
|
||||
check(eq(names(Roles.get_player_roles(env.server)), { "<server>" }), "a player with index 0 is the server")
|
||||
check(Roles.get_player_highest_role(alice) == R("Moderator"), "highest role")
|
||||
check(Roles.get_player_highest_role(carol) == R("Player"), "highest role is the default role when none are held")
|
||||
test("player roles include the default role", function(env)
|
||||
local players = setup(env)
|
||||
check(eq(Test.sorted(Test.names(env.Roles.get_player_roles(players.alice))), { "Moderator", "Player" }),
|
||||
"held roles and the default role")
|
||||
check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Player" }),
|
||||
"a player with no roles has the default role")
|
||||
end)
|
||||
|
||||
check(Roles.player_has_permission(alice, "exp_scenario.command.kill"), "permission through a held role")
|
||||
check(Roles.player_has_permission(alice, "exp_scenario.gui.readme"), "permission through the default role")
|
||||
check(not Roles.player_has_permission(bob, "exp_scenario.command.jail"), "permission not granted")
|
||||
check(Roles.player_has_permission(nil, "anything.at.all"), "the server has every permission")
|
||||
check(Roles.player_has_permission(env.server, "anything.at.all"), "an index 0 player has every permission")
|
||||
test("the server is nil or a player with index 0", function(env)
|
||||
setup(env)
|
||||
check(eq(Test.names(env.Roles.get_player_roles(nil)), { "<server>" }), "nil is the server")
|
||||
check(eq(Test.names(env.Roles.get_player_roles(env.server)), { "<server>" }), "index 0 is the server")
|
||||
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"),
|
||||
"has any passes when one permission is granted")
|
||||
check(not Roles.player_has_any_permission(bob, "exp_scenario.command.jail", "exp_scenario.command.assign_role"),
|
||||
"has any fails when none are granted")
|
||||
check(not Roles.player_has_any_permission(bob), "has any with no permissions is false")
|
||||
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")
|
||||
test("the highest role", function(env)
|
||||
local players = setup(env)
|
||||
check(env.Roles.get_player_highest_role(players.alice) == env.R("Moderator"), "highest held role")
|
||||
check(env.Roles.get_player_highest_role(players.carol) == env.R("Player"), "the default role when none are held")
|
||||
end)
|
||||
|
||||
check(R("Moderator"):has_permission("exp_scenario.command.kill"), "role has_permission")
|
||||
check(not R("Regular"):has_permission("exp_scenario.command.jail"), "role has_permission not granted")
|
||||
check(R("Cluster Admin"):has_permission("anything.at.all"), "core.admin grants everything")
|
||||
test("permission checks", function(env)
|
||||
local players = setup(env)
|
||||
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")
|
||||
check(not R("Moderator"):has_player(bob), "has_player for a role not held")
|
||||
check(R("Player"):has_player(carol), "everyone has the default role")
|
||||
check(not R("Player"):has_player(nil), "the server does not have the default role")
|
||||
test("has any and has all", function(env)
|
||||
local players = setup(env)
|
||||
local jail, kill = "exp_scenario.command.jail", "exp_scenario.command.kill"
|
||||
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")
|
||||
check(not Roles.player_outranks(bob, alice), "a lower role does not outrank a higher one")
|
||||
check(not Roles.player_outranks(alice, alice), "nobody outranks themselves")
|
||||
check(Roles.player_outranks(nil, alice), "the server outranks everyone")
|
||||
check(not Roles.player_outranks(alice, nil), "nobody outranks the server")
|
||||
test("role permission and player methods", function(env)
|
||||
local players = setup(env)
|
||||
check(env.R("Moderator"):has_permission("exp_scenario.command.kill"), "has_permission granted")
|
||||
check(not env.R("Regular"):has_permission("exp_scenario.command.jail"), "has_permission not granted")
|
||||
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()
|
||||
|
||||
+99
-72
@@ -1,91 +1,118 @@
|
||||
--- Initialise semantics and updates from the controller
|
||||
local Env = ...
|
||||
local env = Env.new()
|
||||
local check, eq, R = env.check, env.eq, env.R
|
||||
local Roles = env.Roles
|
||||
local sd = Roles._script_data()
|
||||
local Test = Env.Test
|
||||
local test, check, eq = Test.test, Test.check, Test.eq
|
||||
|
||||
local alice = env.add_player("alice", 1)
|
||||
local carol = env.add_player("carol", 3)
|
||||
|
||||
local admin_state = {}
|
||||
Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state)
|
||||
admin_state[player.name] = state
|
||||
end)
|
||||
|
||||
env.initialise{
|
||||
--- alice is a moderator and carol has only the default role, both connected
|
||||
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
|
||||
}
|
||||
Roles.set_emit_events(true)
|
||||
}
|
||||
env.Roles.set_emit_events(true)
|
||||
return players
|
||||
end
|
||||
|
||||
check(admin_state.alice == true and admin_state.carol == false, "triggers run for connected players on initialise")
|
||||
check(#env.events == 2, "the roles changed event is raised once per connected player on initialise")
|
||||
check(#env.events[1].data.assigned == 0 and #env.events[1].data.unassigned == 0, "initialise events are empty")
|
||||
check(#env.printed == 0 and #env.sent == 0 and #env.sounds == 0, "initialise is silent and sends nothing")
|
||||
test("initialise applies triggers and raises empty events", function(env)
|
||||
setup(env)
|
||||
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)
|
||||
|
||||
-- Changes to the roles a player holds, made on the controller
|
||||
env.reset_log()
|
||||
Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) }
|
||||
check(R("Moderator"):has_player(carol), "a controller assignment applies")
|
||||
check(#env.events == 1 and eq(env.events[1].data.assigned, { 5 }), "a controller assignment raises the event")
|
||||
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>", "a controller assignment is announced by the server")
|
||||
check(admin_state.carol == true, "triggers follow controller assignments")
|
||||
test("controller assignments apply and are announced", function(env)
|
||||
local players = setup(env)
|
||||
env.reset_log()
|
||||
env.Roles.receive_assignment_updates{ Env.assignment("carol", { 5 }) }
|
||||
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")
|
||||
|
||||
env.reset_log()
|
||||
Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } }
|
||||
check(not R("Moderator"):has_player(carol), "a controller removal applies")
|
||||
check(#env.events == 1 and eq(env.events[1].data.unassigned, { 5 }), "a controller removal raises the event")
|
||||
check(admin_state.carol == false, "triggers follow controller removals")
|
||||
env.reset_log()
|
||||
env.Roles.receive_assignment_updates{ { name = "carol", role_ids = {}, is_deleted = true } }
|
||||
check(not env.R("Moderator"):has_player(players.carol), "a removal applies")
|
||||
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)
|
||||
|
||||
env.reset_log()
|
||||
Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) }
|
||||
check(#env.events == 0 and #env.printed == 0, "changes for players not on this map raise nothing")
|
||||
test("changes for players not on this map raise nothing", function(env)
|
||||
setup(env)
|
||||
env.reset_log()
|
||||
env.Roles.receive_assignment_updates{ Env.assignment("zed", { 5 }) }
|
||||
check(#env.events == 0 and #env.printed == 0, "no event and no announcement")
|
||||
end)
|
||||
|
||||
-- Changes to the roles themselves
|
||||
env.reset_log()
|
||||
Roles.receive_role_updates{
|
||||
test("role updates apply to their holders", function(env)
|
||||
setup(env)
|
||||
env.reset_log()
|
||||
env.Roles.receive_role_updates{
|
||||
{ id = 6, name = "Regular", permissions = { "exp_scenario.command.jail" }, meta = { id = 0, order = 3 } },
|
||||
}
|
||||
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.events[1].data.assigned == 0, "role change events are empty")
|
||||
}
|
||||
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()
|
||||
Roles.receive_role_updates{ { id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true } }
|
||||
check(Roles.get_role(6) == nil, "a deleted role is removed")
|
||||
env.Roles.receive_role_updates{
|
||||
{ id = 6, name = "Regular", permissions = {}, meta = { id = 0, order = 3 }, is_deleted = true },
|
||||
}
|
||||
check(env.Roles.get_role(6) == nil, "a deleted role is removed")
|
||||
end)
|
||||
|
||||
env.reset_log()
|
||||
Roles.receive_role_updates{
|
||||
test("the default role follows is_default", function(env)
|
||||
local players = setup(env)
|
||||
env.Roles.receive_role_updates{
|
||||
{ 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")
|
||||
}
|
||||
check(env.Roles.get_default_role() == env.R("Guest"), "the new default role is known")
|
||||
check(eq(Test.names(env.Roles.get_player_roles(players.carol)), { "Guest" }), "players pick up the new default")
|
||||
end)
|
||||
|
||||
-- Initialise is authoritative for pending roles
|
||||
R("Moderator"):assign(carol)
|
||||
R("Jail"):assign(carol, { silent = true, local_only = true })
|
||||
check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise")
|
||||
env.reset_log()
|
||||
env.initialise{ Env.assignment("alice", { 5 }) }
|
||||
check(sd.pending.carol == nil, "initialise clears pending roles")
|
||||
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")
|
||||
test("initialise is authoritative for pending roles", function(env)
|
||||
local players = setup(env)
|
||||
env.R("Moderator"):assign(players.carol)
|
||||
env.R("Jail"):assign(players.carol, { silent = true, local_only = true })
|
||||
|
||||
-- Emit updates gate and load
|
||||
Roles.set_emit_events(false)
|
||||
env.reset_log()
|
||||
R("Regular"):assign(alice)
|
||||
check(#env.sent == 0, "nothing is sent while emit is disabled")
|
||||
Roles.on_load()
|
||||
check(R("Moderator"):is_higher_than(R("Regular")), "roles are usable after load")
|
||||
local sd = env.Roles._script_data()
|
||||
check(eq(sd.pending.carol, { 5 }), "the role is pending before initialise")
|
||||
|
||||
-- Triggers on join
|
||||
admin_state.alice = nil
|
||||
Roles.on_player_joined_game{ player_index = 1 }
|
||||
check(admin_state.alice ~= nil, "triggers run when a player joins")
|
||||
env.reset_log()
|
||||
env.initialise{ Env.assignment("alice", { 5 }) }
|
||||
check(sd.pending.carol == nil, "pending roles are cleared")
|
||||
check(not env.R("Moderator"):has_player(players.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")
|
||||
end)
|
||||
|
||||
return Env.results_json(env)
|
||||
test("nothing is sent while emit is disabled", function(env)
|
||||
local players = setup(env)
|
||||
env.Roles.set_emit_events(false)
|
||||
env.reset_log()
|
||||
env.R("Regular"):assign(players.alice)
|
||||
check(#env.sent == 0, "the assignment is not sent")
|
||||
check(env.R("Regular"):has_player(players.alice), "the assignment still applies locally")
|
||||
end)
|
||||
|
||||
test("roles are usable after load and triggers run on join", function(env)
|
||||
setup(env)
|
||||
env.Roles.on_load()
|
||||
check(env.R("Moderator"):is_higher_than(env.R("Regular")), "roles are usable after load")
|
||||
|
||||
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";
|
||||
const t = require("tap");
|
||||
const { Value } = require("@sinclair/typebox/value");
|
||||
const messages = require("../dist/node/messages");
|
||||
const { testMatrix, testRoundTripJsonSerialisable } = require("../../test/common");
|
||||
|
||||
function roundTrip(subtest, Record, record) {
|
||||
const json = record.toJSON();
|
||||
subtest.ok(Value.Check(Record.jsonSchema, json), "json matches the schema");
|
||||
subtest.strictSame(Record.fromJSON(json), record, "the record round trips");
|
||||
}
|
||||
const fullMeta = new messages.RoleMetaRecord(
|
||||
7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false,
|
||||
);
|
||||
|
||||
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 => {
|
||||
roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord(7, 3));
|
||||
roundTrip(subtest, messages.RoleMetaRecord, new messages.RoleMetaRecord(
|
||||
7, 3, 1, "Mod", "[Mod]", new messages.RoleColor(1, 2, 3), 3600000, true, 12345, false,
|
||||
testRoundTripJsonSerialisable(messages.RoleMetaRecord, testMatrix(
|
||||
[7], // id
|
||||
[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();
|
||||
});
|
||||
|
||||
t.test("RoleRecord", subtest => {
|
||||
roundTrip(subtest, messages.RoleRecord, new messages.RoleRecord(
|
||||
7, "Moderator", ["exp_scenario.command.kill"], new messages.RoleMetaRecord(7, 3), true, 12345,
|
||||
testRoundTripJsonSerialisable(messages.RoleRecord, testMatrix(
|
||||
[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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use strict";
|
||||
const path = require("node:path");
|
||||
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"]) {
|
||||
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 { 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.
|
||||
*
|
||||
* 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
|
||||
* from env.lua and must return an array of { name, ok, detail? } results,
|
||||
* encoded as JSON.
|
||||
* gets an independent set of stubs. Three chunks run in order, each taking one
|
||||
* argument and returning one value:
|
||||
*
|
||||
* @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 }[]}
|
||||
*/
|
||||
function runLuaTests(name) {
|
||||
function runLuaTests(envFile, testFile) {
|
||||
const L = lauxlib.luaL_newstate();
|
||||
lualib.luaL_openlibs(L);
|
||||
|
||||
// Each file is run as a chunk taking one argument and returning one value
|
||||
const load = (file, chunkName) => {
|
||||
const load = (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) {
|
||||
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(path.join(luaRoot, "env.lua"), "test/lua/env.lua");
|
||||
lua.lua_pushstring(L, to_luastring(moduleRoot));
|
||||
load(envFile);
|
||||
lua.lua_pushstring(L, to_luastring(__dirname));
|
||||
call();
|
||||
|
||||
// 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);
|
||||
call();
|
||||
|
||||
@@ -49,10 +50,10 @@ function runLuaTests(name) {
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
/** Report lua results through a tap test object. */
|
||||
function reportLuaTests(t, name) {
|
||||
const results = runLuaTests(name);
|
||||
t.ok(results.length > 0, `${name} produced results`);
|
||||
/** Report the results of a lua test file through a tap test object. */
|
||||
function reportLuaTests(t, envFile, testFile) {
|
||||
const results = runLuaTests(envFile, testFile);
|
||||
t.ok(results.length > 0, `${path.basename(testFile)} produced results`);
|
||||
for (const result of results) {
|
||||
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