Cover the player-sync ctl commands in tests

Codecov flagged ctl.ts at 21% patch coverage. Run each command
through a fake control link with canned responses and check what it
prints and sends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Fable 5.1 committed 2026-10-17 18:52:29 +00:00
1 parent a81d98e981
commit 9cc5c64852
1 file changed
+135
+135
View File
@@ -1,8 +1,17 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import * as lib from "@clusterio/lib";
import { CtlHooks } from "@clusterio/ctl";
import * as mock from "../../../test/mock.js";
import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js";
import * as msg from "../dist/node/messages.js";
import { summarizeInventory, summarizePlayerInventories } from "../dist/node/player_data.js";
import ctlEntrypoint from "../dist/node/ctl.js";
import { plugin as info } from "../dist/node/index.js";
function playerData(name, generation, extra = {}) {
@@ -56,6 +65,10 @@ describe("player_sync plugin", function() {
});
it("should handle empty inventories", function() {
assert.deepEqual(summarizeInventory({}), []);
assert.deepEqual(
summarizeInventory({ i: [{ n: "wood" }] }),
[{ name: "wood", quality: undefined, count: 0 }],
);
});
});
@@ -77,6 +90,128 @@ describe("player_sync plugin", function() {
});
it("should handle missing inventories", function() {
assert.deepEqual(summarizePlayerInventories(playerData("player", 1)), []);
assert.deepEqual(summarizePlayerInventories(playerData("player", 1, { character: {} })), []);
});
});
describe("ctl commands", function() {
let commands;
let sent;
let printed;
let responses;
const ctl = {
async send(message) {
sent.push(message);
const handler = responses.get(message.constructor);
if (!handler) {
throw new Error(`Unexpected ${message.constructor.name}`);
}
return handler(message);
},
};
const storedData = playerData("alice", 2, {
character: { inventories: { main: { i: [{ n: "wood", c: 5, q: "rare" }, { e: "0eNq" }] } } },
});
let tempDir;
before(async function() {
const root = new lib.CommandTree({ name: "clusterioctl", description: "root" });
const hooks = new CtlHooks(new mock.MockLogger());
await ctlEntrypoint({ hooks, logger: new mock.MockLogger(), plugin: info });
await hooks.addCommands.invoke(root);
commands = root.subCommands.get("player-sync").subCommands;
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "player_sync-ctl-"));
});
after(async function() {
await fs.rm(tempDir, { recursive: true, force: true });
});
beforeEach(function() {
sent = [];
printed = [];
responses = new Map([
[lib.InstanceDetailsGetRequest, request => ({ id: request.instanceId, name: "Test" })],
[msg.ListPlayersRequest, () => [
new msg.PlayerEntry("bob", undefined, undefined, 1),
new msg.PlayerEntry("alice", 2, 100),
]],
[msg.GetPlayerDataRequest, request => new msg.GetPlayerDataResponse(
request.playerName === "alice" ? storedData : null, request.playerName === "alice" ? 1 : undefined
)],
[msg.SetPlayerDataRequest, () => new msg.SetPlayerDataResponse(3)],
[msg.DeletePlayerDataRequest, () => undefined],
[msg.ForceReleaseRequest, () => undefined],
]);
});
async function run(name, args = {}) {
/* eslint-disable no-console */
const original = console.log;
console.log = (...values) => printed.push(values.join(" "));
try {
await commands.get(name).run(args, ctl);
} finally {
console.log = original;
}
/* eslint-enable no-console */
}
it("list should print sorted entries", async function() {
await run("list");
assert.deepEqual(printed, [
"name | generation | size | acquired by",
`alice | 2 | ${lib.formatBytes(100)} | -`,
"bob | - | - | Test (1)",
]);
});
it("show should print a summary", async function() {
await run("show", { player: "alice" });
assert.deepEqual(printed, [
"acquired by: Test (1)",
"generation: 2",
`size: ${lib.formatBytes(JSON.stringify(storedData).length)}`,
"controller: character",
"force: player",
"main:",
" 5 wood (rare)",
` 1 exported item ${lib.formatBytes(4)}`,
]);
});
it("show should handle players without data", async function() {
await run("show", { player: "carol" });
assert.deepEqual(printed, ["acquired by: -", "No player data stored for carol"]);
});
it("export should print or write JSON", async function() {
await run("export", { player: "alice" });
assert.deepEqual(JSON.parse(printed.join("\n")), storedData);
const file = path.join(tempDir, "alice.json");
await run("export", { player: "alice", file });
assert.deepEqual(JSON.parse(await fs.readFile(file, "utf8")), storedData);
await assert.rejects(
run("export", { player: "carol" }),
new lib.CommandError("No player data stored for carol"),
);
});
it("import should send the file contents", async function() {
const file = path.join(tempDir, "import.json");
await fs.writeFile(file, JSON.stringify({ name: "other", controller: "god" }));
await run("import", { player: "alice", file });
assert.deepEqual(sent, [
new msg.SetPlayerDataRequest("alice", { name: "alice", controller: "god", generation: 0 }),
]);
assert.deepEqual(printed, ["Stored player data for alice as generation 3"]);
});
it("import should reject non-object JSON", async function() {
const file = path.join(tempDir, "bad.json");
await fs.writeFile(file, "[1]");
await assert.rejects(
run("import", { player: "alice", file }),
new lib.CommandError("Player data must be a JSON object"),
);
});
it("delete and release should send requests", async function() {
await run("delete", { player: "alice" });
await run("release", { player: "alice" });
assert.deepEqual(sent, [new msg.DeletePlayerDataRequest("alice"), new msg.ForceReleaseRequest("alice")]);
});
});
});