Files
factorio-clusterio/test/host/patch.js
T
bbassieandClaude Opus 5.5 513d964a8b Remove scenario.lua when unpatching a save
Unpatching moves scenario.lua back to control.lua instead of copying it, so the save only keeps clusterio.json for the patch number. Patching such a save again copies control.lua back to scenario.lua.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:21:19 +00:00

363 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import * as lib from "@clusterio/lib";
import * as patch from "@clusterio/host/dist/node/src/patch.js";
import { createZipBuffer } from "../common.js";
describe("host/patch", function() {
describe("class SaveModule", function() {
describe("static moduleFilePath()", function() {
it("Should remap locale files", function() {
const mappings = [
["locale/en/foo.cfg", "locale/en/test-foo.cfg"],
["locale/en/foo.txt", "locale/en/test-foo.txt"],
["locale/en/foo.tmp.txt", "locale/en/test-foo.tmp.txt"],
["locale/en/foo", "locale/en/test-foo"],
["locale/en.cfg", "locale/en/test.cfg"],
["locale/en.txt", "locale/en/test.txt"],
["locale/en.tmp.txt", "locale/en/test.tmp.txt"],
["locale/en", "locale/en/test"],
["locale/en/bar/foo.cfg", "locale/en/test-bar/foo.cfg"],
];
for (let [input, expected] of mappings) {
assert.equal(patch.SaveModule.moduleFilePath(input, "test"), expected);
}
});
});
describe("loadFiles()", function() {
const testModule = new patch.SaveModule(new lib.ModuleInfo("test", "1.0.0"));
const referenceFiles = new Map([
["modules/test/test.lua", "-- test\n"],
["modules/test/module_exports.lua", "-- module_exports\n"],
["locale/en/test.cfg", "module-test=A Test\n"],
["locale/en/test-locale.cfg", "module-test-locale=Test Locale\n"],
["modules/test.lua", "return require(\"modules/test/module_exports\")"],
]);
before(async function() {
await testModule.loadFiles("test/file/modules/test");
});
it("Should read the contents of all files in a module", function() {
assert.equal(testModule.files.size, referenceFiles.size);
for (let [relativePath, contents] of referenceFiles) {
if (!testModule.files.has(relativePath)) {
assert.fail(`Module is missing file: ${relativePath}`);
} else {
assert.equal(testModule.files.get(relativePath).toString("utf-8"), contents);
}
}
});
});
});
describe("generateLoader()", function() {
let reference = [
"-- Auto generated scenario module loader created by Clusterio",
"-- Modifications to this file will be lost when loaded in Clusterio",
"clusterio_patch_number = 1",
"",
'local event_handler = require("event_handler")',
"",
"-- Scenario modules",
'event_handler.add_lib(require("foo"))',
"",
"-- Clusterio modules",
'event_handler.add_lib(require("modules/spam/bar"))',
'require("modules/spam/spam")',
"",
].join("\n");
let patchInfo = new patch.PatchInfo(
1,
new lib.ModuleInfo("foo", "0.0.0", ["foo"]),
[
new patch.SaveModule(
new lib.ModuleInfo(
"spam",
"0.0.0",
["bar.lua"],
["spam.lua"],
),
new Map([
["bar.lua", Buffer.from("")],
["spam.lua", Buffer.from("")],
["excluded.lua", Buffer.from("")],
]),
),
],
);
it("should produce output matching the reference", function() {
assert.equal(patch._generateLoader(patchInfo), reference);
});
});
function module({ name, version, dependencies = {} }) {
const dependencyMap = new Map(Object.entries(dependencies));
return new patch.SaveModule(new lib.ModuleInfo(name, version, [], [], dependencyMap));
}
describe("reorderDependencies()", function() {
it("should reorder to satisfy simple dependency", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {} }),
];
patch._reorderDependencies(modules);
assert(modules[0].info.name === "b", "Dependency was not reorederd");
});
it("should throw on invalid version", function() {
let modules = [
module({ name: "a", version: "foo", dependencies: {} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Invalid version 'foo' for module a")
);
});
it("should throw on invalid version range", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "invalid"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Invalid version range 'invalid' for dependency b on module a")
);
});
it("should throw on missing dependency", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Missing dependency b for module a")
);
modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {"c": "*"} }),
module({ name: "c", version: "1.0.0", dependencies: {"d": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Missing dependency d for module c")
);
});
it("should throw on outdated dependency", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {} }),
module({ name: "b", version: "1.0.0", dependencies: {"a": ">=2"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Module b requires a >=2")
);
});
it("should throw on dependency loops", function() {
let modules = [
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {"a": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Module dependency loop detected: a -> b -> a")
);
modules = [
module({ name: "d", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "a", version: "1.0.0", dependencies: {"b": "*"} }),
module({ name: "b", version: "1.0.0", dependencies: {"c": "*"} }),
module({ name: "c", version: "1.0.0", dependencies: {"a": "*"} }),
];
assert.throws(
() => patch._reorderDependencies(modules),
new Error("Module dependency loop detected: b -> c -> a -> b")
);
});
it("should reorder correctly for multiple dependencies", function() {
function validate(modules) {
let present = new Set();
for (let { info } of modules) {
for (let dependency of info.dependencies.keys()) {
if (!present.has(dependency)) {
assert.fail(`${info.name} depends on ${dependency}, but was ordered before it`);
}
}
present.add(info.name);
}
}
let names = ["a", "b", "c"];
let successCount = 0;
const bitCount = names.length * (names.length - 1);
for (let i=0; i < 2**bitCount; i++) {
let value = i;
let bits = [];
for (let j=0; j < bitCount; j++) {
bits.push(value & 1);
value >>= 1;
}
let modules = [];
for (let j=0; j < names.length; j++) {
let name = names[j];
let dependencies = {};
for (let k=0; k < names.length - 1; k++) {
if (bits[k + j * (names.length - 1)]) {
dependencies[names[k + (k >= j)]] = "*";
}
}
modules.push(module({ name, version: "1.0.0", dependencies }));
}
let success = false;
try {
patch._reorderDependencies(modules);
success = true;
} catch (err) { }
if (success) {
validate(modules);
successCount += 1;
}
}
// Deriving the formula for the number of graphs without
// dependencies loops is left as an excercise for the reader.
assert.equal(successCount, 25, "Incorrect number of successful orderings");
});
});
describe("loadModules()", function() {
it("should load the bundled clusterio module", async function() {
const modules = await patch.loadModules([]);
assert.deepEqual([...modules.keys()], ["clusterio"]);
assert(modules.get("clusterio").files.has("modules/clusterio/api.lua"));
});
it("should load modules from plugins", async function() {
const pluginInfos = await lib.loadPluginInfos(
new Map([["research_sync", path.resolve("plugins/research_sync")]])
);
const modules = await patch.loadModules(pluginInfos);
assert.deepEqual([...modules.keys()], ["research_sync", "clusterio"]);
assert(modules.get("research_sync").files.has("modules/research_sync/sync.lua"));
});
it("should skip plugins without a module", async function() {
const pluginInfos = await lib.loadPluginInfos(
new Map([["test_plugin_no_module", path.resolve("test/file/test_plugin_no_module")]])
);
const modules = await patch.loadModules(pluginInfos);
assert.deepEqual([...modules.keys()], ["clusterio"]);
});
it("should throw on a plugin module named like a bundled module", async function() {
const pluginDir = path.join("temp", "test", "clusterio_plugin");
await fs.mkdir(path.join(pluginDir, "module"), { recursive: true });
await fs.writeFile(path.join(pluginDir, "package.json"), "{}");
await fs.writeFile(path.join(pluginDir, "module", "module.json"), "{}");
await assert.rejects(
patch.loadModules([{
name: "clusterio", version: "1.0.0", packagePath: path.resolve(pluginDir, "package.json"),
}]),
new Error("Module with name clusterio already exists in a plugin")
);
});
});
describe("patch()", function() {
it("should throw on unknown scenario", async function() {
let zipPath = path.join("temp", "test", "patch.zip");
await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", "-- unknown\n"]]));
await assert.rejects(
patch.patch(zipPath, []),
new Error("Unable to patch save, unknown scenario (3acc3be3861144e55604f5ac2f2555071885ebc4)")
);
});
});
describe("unpatch()", function() {
const freeplayControl = "require('__base__/script/freeplay/control.lua')\n";
const zipPath = path.join("temp", "test", "unpatch.zip");
let testModule;
before(async function() {
testModule = new patch.SaveModule(new lib.ModuleInfo("test", "1.0.0"));
await testModule.loadFiles("test/file/modules/test");
});
async function readSave() {
const zip = await lib.ZipArchive.fromFile(zipPath);
try {
const files = new Map();
for (const name of zip.entries.keys()) {
files.set(name, (await zip.readFile(name)).toString("utf8"));
}
return files;
} finally {
zip.close();
}
}
it("should leave saves that are not patched untouched", async function() {
await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", freeplayControl]]));
assert.equal(await patch.unpatch(zipPath), false);
assert.deepEqual([...(await readSave()).keys()], ["world/control.lua"]);
});
it("should restore control.lua and remove module files", async function() {
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", freeplayControl],
["world/level.dat0", "level"],
]));
await patch.patch(zipPath, [testModule]);
assert.equal(await patch.unpatch(zipPath), true);
const files = await readSave();
assert.deepEqual(
[...files.keys()].sort(),
["world/clusterio.json", "world/control.lua", "world/level.dat0"],
);
assert.equal(files.get("world/control.lua"), freeplayControl);
const patchInfo = JSON.parse(files.get("world/clusterio.json"));
assert.equal(patchInfo.patch_number, 1);
assert.deepEqual(patchInfo.modules, []);
assert.equal(await patch.unpatch(zipPath), false, "unpatched twice");
});
it("should throw on a newer patch version", async function() {
const scenario = new lib.ModuleInfo("freeplay", "2.0.29", [], ["scenario"]);
const patchInfo = { version: 2, patch_number: 1, scenario, modules: [] };
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", freeplayControl],
["world/clusterio.json", JSON.stringify(patchInfo)],
]));
await assert.rejects(patch.unpatch(zipPath), /Save patch version 2 is newer/);
await assert.rejects(patch.patch(zipPath, []), /Save patch version 2 is newer/);
});
it("should keep counting the patch number when patched again", async function() {
await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", freeplayControl]]));
await patch.patch(zipPath, [testModule]);
await patch.unpatch(zipPath);
await patch.patch(zipPath, [testModule]);
const files = await readSave();
assert.equal(JSON.parse(files.get("world/clusterio.json")).patch_number, 2);
assert(files.has("world/modules/test/test.lua"));
assert.equal(files.get("world/scenario.lua"), freeplayControl);
assert.match(files.get("world/control.lua"), /^-- Auto generated scenario module loader/);
});
it("should keep the loader of scenarios made for Clusterio", async function() {
const scenario = new lib.ModuleInfo("custom", "1.0.0", [], ["custom"]);
const patchInfo = new patch.PatchInfo(3, scenario, [testModule]);
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", patch._generateLoader(patchInfo)],
["world/clusterio.json", JSON.stringify(patchInfo)],
["world/custom.lua", "-- custom\n"],
...[...testModule.files].map(([name, content]) => [`world/${name}`, content]),
]));
assert.equal(await patch.unpatch(zipPath), true);
const files = await readSave();
assert.deepEqual(
[...files.keys()].sort(),
["world/clusterio.json", "world/control.lua", "world/custom.lua"],
);
assert.equal(files.get("world/control.lua"), patch._generateLoader(new patch.PatchInfo(3, scenario, [])));
});
});
});