Files
factorio-clusterio/test/lib/data/ModInfo.js
T
bbassieandClaude Opus 4.8 5d20f8fc84 Revert strict ModInfo.jsonSchema and cover the factorio_version error
The strict major.minor jsonSchema did not improve any consuming type (the
factorioVersion field is already typed) and only complicated the fromJSON
signature, so revert it (and drop the now-unused MajorMinorVersionSchema).
Add a test for the unparseable factorio_version branch in fromJSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:20:37 +00:00

438 lines
20 KiB
JavaScript

"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 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);
});
it("should pass for optional / hidden 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 optional / hidden 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 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"]) {
const dependency = new ModDependency(test);
assert.equal(dependency.incompatible, 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"]) {
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"]) {
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 normalise lenient version fields the way the game reads them", function() {
const mod = ModInfo.fromJSON({
name: "my-mod",
version: " 1.2.3-extra",
factorio_version: "1.1.0",
});
assert.equal(mod.version, "1.2.3");
assert.equal(mod.factorioVersion, "1.1");
});
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");
});
});
});
});