"use strict"; const assert = require("assert").strict; const fs = require("node:fs/promises"); const path = require("path"); const lib = require("@clusterio/lib"); const libBuildMod = require("@clusterio/lib/build_mod"); const { ModInfo, ModDependency } = lib; const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); describe("lib/data/ModInfo", function() { describe("class ModDependency", function() { describe(".getTypeFromPrefix()", function() { it("should not throw for valid prefixes", function() { assert.equal(ModDependency.getTypeFromPrefix("!"), "incompatible"); assert.equal(ModDependency.getTypeFromPrefix("?"), "optional"); assert.equal(ModDependency.getTypeFromPrefix("(?)"), "hidden"); assert.equal(ModDependency.getTypeFromPrefix("~"), "unordered"); assert.equal(ModDependency.getTypeFromPrefix(""), "required"); }); it("should throw for unknown prefixes", function() { assert.throws(() => ModDependency.getTypeFromPrefix("invalid")); }); }); describe("constructor", function() { it("should accept specifications of name only", function() { const dependency = new ModDependency("my-mod"); assert.equal(dependency.name, "my-mod"); assert.equal(dependency.type, "required"); assert.equal(dependency.version, undefined); }); it("should accept specifications of name and prefix", function() { const dependency = new ModDependency("? my-mod"); assert.equal(dependency.name, "my-mod"); assert.equal(dependency.type, "optional"); assert.equal(dependency.version, undefined); }); it("should accept specifications of name and version", function() { const dependency = new ModDependency("my-mod >= 1.2.3"); assert.equal(dependency.name, "my-mod"); assert.equal(dependency.type, "required"); assert.notEqual(dependency.version, undefined); assert.equal(dependency.version.equality, ">="); assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3")); }); it("should accept specifications of name, prefix and version", function() { const dependency = new ModDependency("? my-mod >= 1.2.3"); assert.equal(dependency.name, "my-mod"); assert.equal(dependency.type, "optional"); assert.notEqual(dependency.version, undefined); assert.equal(dependency.version.equality, ">="); assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3")); }); it("should ignore the version for incompatible dependencies", function() { const dependency = new ModDependency("! my-mod >= 1.2.3"); assert.equal(dependency.name, "my-mod"); assert.equal(dependency.type, "incompatible"); assert.equal(dependency.version, undefined); }); it("should throw on an empty string", function() { assert.throws(() => new ModDependency("")); }); it("should throw if no version equality is given", function() { this.skip(); // For alignment with factorio, this should be parsed as the mod name assert.throws(() => new ModDependency("my-mod 1.2.3")); assert.throws(() => new ModDependency("? my-mod 1.2.3")); }); it("should parse no version equality as the mod name", function() { const dependencyReq = new ModDependency("my-mod 1.2.3"); assert.equal(dependencyReq.name, "my-mod 1.2.3"); assert.equal(dependencyReq.type, "required"); assert.equal(dependencyReq.version, undefined); const dependencyOpt = new ModDependency("? my-mod 1.2.3"); assert.equal(dependencyOpt.name, "my-mod 1.2.3"); assert.equal(dependencyOpt.type, "optional"); assert.equal(dependencyOpt.version, undefined); }); it("should throw if invalid specification given", function() { this.skip(); // For alignment with factorio, this should be parsed as the mod name assert.throws(() => new ModDependency("& my-mod = 1.2.3")); assert.throws(() => new ModDependency("? ? my-mod = 1.2.3")); }); it("should parse invalid prefix as the mod name", function() { const dependencyInvalid = new ModDependency("& my-mod = 1.2.3"); assert.equal(dependencyInvalid.name, "& my-mod"); assert.equal(dependencyInvalid.type, "required"); assert.equal(dependencyInvalid.version.version, "1.2.3"); const dependencyDupe = new ModDependency("? ? my-mod = 1.2.3"); assert.equal(dependencyDupe.name, "? my-mod"); assert.equal(dependencyDupe.type, "optional"); assert.equal(dependencyDupe.version.version, "1.2.3"); }); it("should not error when the name contains spaces", function() { const dependencyReq = new ModDependency("my mod"); assert.equal(dependencyReq.name, "my mod"); assert.equal(dependencyReq.type, "required"); const dependencyOpt = new ModDependency("? my mod"); assert.equal(dependencyOpt.name, "my mod"); assert.equal(dependencyOpt.type, "optional"); const dependencyVer = new ModDependency("my mod >= 1.2.3"); assert.equal(dependencyVer.name, "my mod"); assert.equal(dependencyVer.type, "required"); assert.equal(dependencyVer.version.version, "1.2.3"); const dependencyOptVer = new ModDependency("? my mod >= 1.2.3"); assert.equal(dependencyOptVer.name, "my mod"); assert.equal(dependencyOptVer.type, "optional"); assert.equal(dependencyOptVer.version.version, "1.2.3"); }); it("should not error when the extra spaces are present", function() { const dependencyReq = new ModDependency(" my mod "); assert.equal(dependencyReq.name, " my mod "); assert.equal(dependencyReq.type, "required"); const dependencyOpt = new ModDependency("? my mod "); assert.equal(dependencyOpt.name, "my mod "); assert.equal(dependencyOpt.type, "optional"); const dependencyVer = new ModDependency("my mod >= 1.2.3"); assert.equal(dependencyVer.name, "my mod"); assert.equal(dependencyVer.type, "required"); assert.equal(dependencyVer.version.version, "1.2.3"); const dependencyOptVer = new ModDependency("? my mod >= 1.2.3"); assert.equal(dependencyOptVer.name, "my mod"); assert.equal(dependencyOptVer.type, "optional"); assert.equal(dependencyOptVer.version.version, "1.2.3"); }); it("should not error when there are no spaces present", function() { const dependencyReq = new ModDependency("my-mod"); assert.equal(dependencyReq.name, "my-mod"); assert.equal(dependencyReq.type, "required"); const dependencyOpt = new ModDependency("?my-mod"); assert.equal(dependencyOpt.name, "my-mod"); assert.equal(dependencyOpt.type, "optional"); const dependencyVer = new ModDependency("my-mod>=1.2.3"); assert.equal(dependencyVer.name, "my-mod"); assert.equal(dependencyVer.type, "required"); assert.equal(dependencyVer.version.version, "1.2.3"); const dependencyOptVer = new ModDependency("?my-mod>=1.2.3"); assert.equal(dependencyOptVer.name, "my-mod"); assert.equal(dependencyOptVer.type, "optional"); assert.equal(dependencyOptVer.version.version, "1.2.3"); }); it("should be round trip json serialisable", function() { testRoundTripJsonSerialisable(ModDependency, testMatrix( ["my-mod", "? my-mod", "! my-mod", "my-mod > 1.0.0", "~ my-mod <= 2.0.0"], // Specification )); }); }); describe("checkUnsatisfiedReason()", function() { const mods = [ ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }), ]; it("should be undefined for incompatible being missing", function() { const dependency = new ModDependency("! not-present"); assert.equal(dependency.checkUnsatisfiedReason(mods), undefined); }); it("should give reason for incompatible being present", function() { const dependency = new ModDependency("! my-mod"); assert.equal(dependency.checkUnsatisfiedReason(mods), "incompatible"); }); it("should undefined for optional / hidden being missing", function() { const dependencyOptional = new ModDependency("? not-present"); assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined); const dependencyHidden = new ModDependency("(?) not-present"); assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined); }); it("should undefined for optional / hidden being present", function() { const dependencyOptional = new ModDependency("? my-mod"); assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined); const dependencyHidden = new ModDependency("(?) my-mod"); assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined); }); it("should give reason for optional / hidden being present but wrong version", function() { const dependencyOptional = new ModDependency("? my-mod > 2.0.0"); assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version"); const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0"); assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version"); }); it("should undefined for unordered / required being present", function() { const dependencyOptional = new ModDependency("~ my-mod"); assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined); const dependencyHidden = new ModDependency("my-mod"); assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined); }); it("should give reason for unordered / required being present but wrong version", function() { const dependencyOptional = new ModDependency("~ my-mod > 2.0.0"); assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version"); const dependencyHidden = new ModDependency("my-mod > 2.0.0"); assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version"); }); it("should give reason for unordered / required being missing", function() { const dependencyOptional = new ModDependency("~ not-present"); assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "missing_dependency"); const dependencyHidden = new ModDependency("not-present"); assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "missing_dependency"); }); }); describe("isSatisfied()", function() { const mods = [ ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }), ]; it("should pass for incompatible being missing", function() { const dependency = new ModDependency("! not-present"); assert.equal(dependency.isSatisfied(mods), true); }); it("should fail for incompatible being present", function() { const dependency = new ModDependency("! my-mod"); assert.equal(dependency.isSatisfied(mods), false); }); it("should pass for optional / hidden / recommended being missing", function() { const dependencyOptional = new ModDependency("? not-present"); assert.equal(dependencyOptional.isSatisfied(mods), true); const dependencyHidden = new ModDependency("(?) not-present"); assert.equal(dependencyHidden.isSatisfied(mods), true); const dependencyRecommended = new ModDependency("+ not-present"); assert.equal(dependencyRecommended.isSatisfied(mods), true); }); it("should pass for optional / hidden / recommended being present", function() { const dependencyOptional = new ModDependency("? my-mod"); assert.equal(dependencyOptional.isSatisfied(mods), true); const dependencyHidden = new ModDependency("(?) my-mod"); assert.equal(dependencyHidden.isSatisfied(mods), true); const dependencyRecommended = new ModDependency("+ my-mod"); assert.equal(dependencyRecommended.isSatisfied(mods), true); }); it("should fail for optional / hidden / recommended being present but wrong version", function() { const dependencyOptional = new ModDependency("? my-mod > 2.0.0"); assert.equal(dependencyOptional.isSatisfied(mods), false); const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0"); assert.equal(dependencyHidden.isSatisfied(mods), false); const dependencyRecommended = new ModDependency("+ my-mod > 2.0.0"); assert.equal(dependencyRecommended.isSatisfied(mods), false); }); it("should pass for unordered / required being present", function() { const dependencyOptional = new ModDependency("~ my-mod"); assert.equal(dependencyOptional.isSatisfied(mods), true); const dependencyHidden = new ModDependency("my-mod"); assert.equal(dependencyHidden.isSatisfied(mods), true); }); it("should fail for unordered / required being present but wrong version", function() { const dependencyOptional = new ModDependency("~ my-mod > 2.0.0"); assert.equal(dependencyOptional.isSatisfied(mods), false); const dependencyHidden = new ModDependency("my-mod > 2.0.0"); assert.equal(dependencyHidden.isSatisfied(mods), false); }); it("should fail for unordered / required being missing", function() { const dependencyOptional = new ModDependency("~ not-present"); assert.equal(dependencyOptional.isSatisfied(mods), false); const dependencyHidden = new ModDependency("not-present"); assert.equal(dependencyHidden.isSatisfied(mods), false); }); }); describe("incompatible", function() { it("should return true iff the type is incompatible", function() { const dependencyIncompatible = new ModDependency("! my-mod"); assert.equal(dependencyIncompatible.incompatible, true); for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod", "+ my-mod"]) { const dependency = new ModDependency(test); assert.equal(dependency.incompatible, false, test); } }); }); describe("recommended", function() { it("should return true iff the type is recommended", function() { const dependencyIncompatible = new ModDependency("+ my-mod"); assert.equal(dependencyIncompatible.recommended, true); for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod", "! my-mod"]) { const dependency = new ModDependency(test); assert.equal(dependency.recommended, false, test); } }); }); describe("required", function() { it("should return true iff the dependency is required", function() { for (const test of ["my-mod", "~ my-mod"]) { const dependency = new ModDependency(test); assert.equal(dependency.required, true, test); } for (const test of ["! my-mod", "? my-mod", "(?) my-mod", "+ my-mod"]) { const dependency = new ModDependency(test); assert.equal(dependency.required, false, test); } }); }); describe("optional", function() { it("should return true iff the dependency is optional", function() { for (const test of ["? my-mod", "(?) my-mod"]) { const dependency = new ModDependency(test); assert.equal(dependency.optional, true, test); } for (const test of ["! my-mod", "my-mod", "~ my-mod", "+ my-mod"]) { const dependency = new ModDependency(test); assert.equal(dependency.optional, false, test); } }); }); }); describe("class ModInfo", function() { it("should round trip serialize", function() { const validate = lib.compile(ModInfo.jsonSchema); function check(pack) { const json = JSON.parse(JSON.stringify(pack)); if (!validate(json)) { throw validate.errors; } assert.deepEqual(ModInfo.fromJSON(json), pack); } check(ModInfo.fromJSON({})); check(ModInfo.fromJSON({ name: "MyMod" })); check(ModInfo.fromJSON({ version: "1.0.0" })); check(ModInfo.fromJSON({ title: "My Extravagent Mod" })); check(ModInfo.fromJSON({ author: "Me" })); check(ModInfo.fromJSON({ contact: "Use my email" })); check(ModInfo.fromJSON({ homepage: "It's over there" })); check(ModInfo.fromJSON({ description: "My Description" })); check(ModInfo.fromJSON({ factorio_version: "2.0" })); check(ModInfo.fromJSON({ dependencies: [] })); check(ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] })); check(ModInfo.fromJSON({ filename: "MyMod_1.0.0.zip" })); check(ModInfo.fromJSON({ size: 1024 })); check(ModInfo.fromJSON({ mtime_ms: new Date(2024, 0, 1).getTime() })); check(ModInfo.fromJSON({ sha1: "verified-as-MyMod" })); check(ModInfo.fromJSON({ is_deleted: true })); // All at once check(ModInfo.fromJSON({ name: "MyMod", version: "1.0.0", title: "My Extravagent Mod", author: "Me", contact: "Use my email", homepage: "It's over there", description: "My Description", factorio_version: "2.0", dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"], filename: "MyMod_1.0.0.zip", size: 1024, mtime_ms: new Date(2024, 0, 1).getTime(), sha1: "verified-as-MyMod", is_deleted: true, })); }); it("should throw an error if given invalid dependencies", function() { assert.throws(() => ModInfo.fromJSON({ name: "my-mod", dependencies: ["base", ""], }), /my-mod/); // check the mod name is included in the error message }); it("should keep the raw mod version but normalise it for comparison", function() { const mod = ModInfo.fromJSON({ name: "my-mod", version: " 1.2.3-extra", factorio_version: "1.1.0", }); // The version is kept verbatim so it still matches the mod's file name, // but is read leniently the way the game does when compared. assert.equal(mod.version, " 1.2.3-extra"); assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.2.3" }).integerVersion); // factorio_version is not part of any file name, so it is reduced to major.minor. assert.equal(mod.factorioVersion, "1.1"); }); it("should keep leading zeros in the version so the file name matches", function() { // Mirrors the mods.factorio.com/mod/leading-zeros case: the version has // a zero-padded component that the file name keeps verbatim. const mod = ModInfo.fromJSON({ name: "leading-zeros", version: "1.01.0" }); assert.equal(mod.version, "1.01.0"); assert.equal(mod.filename, "leading-zeros_1.01.0.zip"); assert.equal(mod.integerVersion, ModInfo.fromJSON({ version: "1.1.0" }).integerVersion); }); it("should throw for an unparseable mod version", function() { assert.throws( () => ModInfo.fromJSON({ name: "my-mod", version: "not-a-version" }), /Invalid mod version/ ); }); it("should throw for an unparseable factorio_version", function() { assert.throws( () => ModInfo.fromJSON({ name: "my-mod", factorio_version: "not-a-version" }), /Invalid factorio_version/ ); }); it("should sort integer mod versions lexicographically", function() { let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"]; let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"]; let mods = unsortedVersions.map(v => ModInfo.fromJSON({ version: v })); mods.sort((a, b) => a.integerVersion - b.integerVersion); assert.deepEqual(mods.map(mod => mod.version), sortedVersions); }); it("should sort integer factorio versions lexicographically", function() { let unsortedVersions = ["1.0", "1.1", "0.1", "3.0", "1.2", "0.3", "2.1"]; let sortedVersions = ["0.1", "0.3", "1.0", "1.1", "1.2", "2.1", "3.0"]; let factorioMods = unsortedVersions.map(v => ModInfo.fromJSON({ factorio_version: v })); factorioMods.sort((a, b) => a.integerFactorioVersion - b.integerFactorioVersion); assert.deepEqual(factorioMods.map(mod => mod.factorioVersion), sortedVersions); }); it("should provide the original string specifications for dependencies", function() { const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] }); assert.deepEqual(mod.dependencySpecifications, ["UltraMod", "SuperLib >= 1.00", "! bad-mod"]); }); it("should parse the provided dependency specifications", function() { const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] }); assert.deepEqual(mod.dependencies, ["UltraMod", "SuperLib >= 1.00", "! bad-mod"].map(dep => new ModDependency(dep)) ); }); it("should load from a mod zip file", async function() { await libBuildMod.build({ build: true, pack: true, sourceDir: path.join("test", "file", "empty_mod"), outputDir: path.join("temp", "test"), }); const filePath = path.join("temp", "test", "empty_mod_1.0.0.zip"); const hash = await lib.hashFile(filePath); const stat = await fs.stat(filePath); const mod = await ModInfo.fromModFile(filePath); assert.deepEqual( mod, ModInfo.fromJSON({ name: "empty_mod", version: "1.0.0", title: "An Empty Mod", author: "Me", description: "An empty mod for testing", factorio_version: "1.1", dependencies: [], filename: "empty_mod_1.0.0.zip", size: stat.size, mtime_ms: stat.mtimeMs, updated_at_ms: stat.mtimeMs, sha1: hash, }) ); }); describe("checkDependencySatisfaction()", function() { it("should return undefined when satisfied", function() { const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "1.0.0" }]), undefined); }); it("should return incompatible when there is an incompatibility", function() { const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); assert.equal(mod.checkDependencySatisfaction([{ name: "a", version: "1.0.0" }]), "incompatible"); }); it("should return missing dependency when missing but no incompatibly", function() { const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); assert.equal(mod.checkDependencySatisfaction([{ name: "c", version: "1.0.0" }]), "missing_dependency"); }); it("should return wrong version when wrong but not missing or incompatible", function() { const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "2.0.0" }]), "wrong_version"); }); }); }); });