Files
factorio-clusterio/test/lib/data/ModInfo.js
T
bbassieandClaude Opus 4.8 8029ad3b2d Resolve GameVersion type as a plain-string supertype
Cooldude's GameVersion brand was disjoint from the clean version types in
both directions, forcing safeVersion/safeFactorioVersion companions and
breaking widening from FullVersion at file-name call sites.

Make GameVersion a plain string alias: the raw, un-normalised version as the
game reads it. The stricter template-literal types (FullVersion,
MajorMinorVersion, PartialVersion) now widen into it for free, while a
GameVersion still can't be used where a normalised version is required
without going through normaliseGameVersion/integerGameVersion. Runtime
validation stays in GameVersionSchema.

- Drop safeVersion/safeFactorioVersion; ModInfo.version stays raw (verbatim so
  it matches the mod's file name), integerVersion normalises via the new
  integerGameVersion. factorioVersion reverts to a normalised MajorMinorVersion
  since it is never part of a file name.
- ModRecord.version becomes a GameVersion so mod packs round-trip the exact
  file-name version.
- ModVersionEquality.testVersion / ModVersionRange.testVersion accept a raw
  GameVersion and normalise it; the equality bound stays strictly validated.
- Migration only pads clean partial versions; raw versions are kept verbatim.
- Cover the leading-zeros case: version kept verbatim in the file name while
  comparing equal to its normalised form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:54:06 +00:00

450 lines
21 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 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");
});
});
});
});